Compare commits
139
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8cdb4ca1 | ||
|
|
dfa2859ea3 | ||
|
|
a68fca6f68 | ||
|
|
f7a14cb99f | ||
|
|
02f9e1b7ad | ||
|
|
5350700e47 | ||
|
|
67a31db615 | ||
|
|
ec3ff5cc01 | ||
|
|
7c2c319538 | ||
|
|
3815aac382 | ||
|
|
4136705d97 | ||
|
|
14b740fc60 | ||
|
|
763d1695f2 | ||
|
|
bcefa8491d | ||
|
|
f89bcd0496 | ||
|
|
08322dc8c2 | ||
|
|
fab7a0af7b | ||
|
|
ce6a3787de | ||
|
|
a277a0dc4c | ||
|
|
f87b1f4693 | ||
|
|
b3475ed624 | ||
|
|
92c3600fc4 | ||
|
|
363a7834aa | ||
|
|
be54c54a5d | ||
|
|
6f3d09009d | ||
|
|
d6583084ec | ||
|
|
993ccfc7d8 | ||
|
|
8abbdec4fd | ||
|
|
64dba42bf3 | ||
|
|
a3ded70600 | ||
|
|
fdde47bbc3 | ||
|
|
c16bbe786f | ||
|
|
ab816a54aa | ||
|
|
2d3ba7fa5d | ||
|
|
7c5fc2a133 | ||
|
|
cb9ae0c0a6 | ||
|
|
ad31e11320 | ||
|
|
eedb4d29ea | ||
|
|
4639e4b818 | ||
|
|
95e73dd4b1 | ||
|
|
05e2f1ddef | ||
|
|
4ef0c50a97 | ||
|
|
6c2ac9cd91 | ||
|
|
318fbcef27 | ||
|
|
4db897abc1 | ||
|
|
5f78012b7d | ||
|
|
eed57c9abd | ||
|
|
179351d1fe | ||
|
|
f1f20384eb | ||
|
|
4b2ceb1bf6 | ||
|
|
eda9315a7b | ||
|
|
3a848a7a9c | ||
|
|
11b0723a92 | ||
|
|
f6440f6af4 | ||
|
|
462af76989 | ||
|
|
4b17446ed6 | ||
|
|
252054f9b6 | ||
|
|
4193deb220 | ||
|
|
075d44a7fa | ||
|
|
5e696c9131 | ||
|
|
5f1646c14b | ||
|
|
30b04d4d14 | ||
|
|
e592c9f3ac | ||
|
|
fa5a2ff899 | ||
|
|
45d6efd8ab | ||
|
|
d6b8466bcd | ||
|
|
7fc5e60007 | ||
|
|
4e4f34be1f | ||
|
|
09076f6555 | ||
|
|
dfcf690d9a | ||
|
|
ad1fe871f4 | ||
|
|
f587807123 | ||
|
|
6154e58da7 | ||
|
|
85d3226175 | ||
|
|
47f20c8f3d | ||
|
|
074c172f52 | ||
|
|
9986ff92b6 | ||
|
|
ce694a20b4 | ||
|
|
ef5933a90e | ||
|
|
ce66f7505c | ||
|
|
41024954b7 | ||
|
|
1af6a1803a | ||
|
|
446f0f993a | ||
|
|
3c88536e2a | ||
|
|
34b71467e7 | ||
|
|
7f2e40225c | ||
|
|
33b0bc578f | ||
|
|
aa128184f8 | ||
|
|
47f6cac432 | ||
|
|
1c9d15116c | ||
|
|
679d011d17 | ||
|
|
1d4b5eba35 | ||
|
|
ad328d156a | ||
|
|
1e5c206d31 | ||
|
|
8b66eedaba | ||
|
|
ea7465b853 | ||
|
|
c5d19039c4 | ||
|
|
9cc072ed5d | ||
|
|
0c3e3f6f78 | ||
|
|
922565f5e3 | ||
|
|
90487f57fc | ||
|
|
cee50acbc4 | ||
|
|
84fd4011ab | ||
|
|
ef5f698746 | ||
|
|
2603ca1bfb | ||
|
|
9791c0891d | ||
|
|
93a745c9ca | ||
|
|
78980e9f1f | ||
|
|
b30c3578a7 | ||
|
|
ef5c3df88b | ||
|
|
e35752ddbc | ||
|
|
5b5d62a2af | ||
|
|
cdd111869b | ||
|
|
4fd6c028af | ||
|
|
abcc77204b | ||
|
|
dce8e971be | ||
|
|
2249974daa | ||
|
|
f49e3dd5b1 | ||
|
|
51c471a359 | ||
|
|
1f05bdd543 | ||
|
|
2250f83d0f | ||
|
|
30fdbed7f2 | ||
|
|
3ff1c91d84 | ||
|
|
b9561cef58 | ||
|
|
ee1b9cb95f | ||
|
|
c1154686d8 | ||
|
|
3ead32a4e8 | ||
|
|
6f1ecc47e8 | ||
|
|
3d80e13f4f | ||
|
|
75e2bcd460 | ||
|
|
691eca212b | ||
|
|
e21a0883c1 | ||
|
|
ba1c13e37c | ||
|
|
1e2f6f234e | ||
|
|
6813e8d34f | ||
|
|
e348d8accf | ||
|
|
b71bbc2d3b | ||
|
|
69e18efab6 | ||
|
|
196287fc8a |
@@ -25,6 +25,8 @@ HUMAN_INPUT_FORM_INPUT_EXAMPLE = {
|
||||
|
||||
|
||||
class HumanInputFormSubmitPayload(BaseModel):
|
||||
"""Legacy Human Input v1 submit payload shared by existing runtime surfaces."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
description=(
|
||||
"Submitted human input values keyed by output variable name. "
|
||||
|
||||
@@ -0,0 +1,984 @@
|
||||
"""Shared Human Input v2 transport contracts.
|
||||
|
||||
Request DTOs use normal Pydantic coercion and forbid unknown fields. Migration
|
||||
input is the sole compatibility exception: it ignores unknown legacy fields,
|
||||
defaults a missing version to ``"1"``, rejects any other explicit version, and
|
||||
rejects duplicate node IDs. Its transport shape mirrors the frontend migration
|
||||
adapter so the generated client can replace the temporary mock without changing
|
||||
frontend orchestration.
|
||||
Public v2, trusted Service API v2, and legacy v1 submit DTOs stay independent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Annotated, Literal, Self, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Discriminator, Field, JsonValue, field_validator, model_validator
|
||||
|
||||
from core.human_input_v2.entities import (
|
||||
ContactId,
|
||||
EmailProviderType,
|
||||
HumanInputContactType,
|
||||
IMBindingId,
|
||||
IMBindingScope,
|
||||
IMIdentityBindingStatus,
|
||||
IMIdentityId,
|
||||
IMIntegrationStatus,
|
||||
IMProvider,
|
||||
IMSyncRemovalReason,
|
||||
IMSyncResultType,
|
||||
IMSyncRunId,
|
||||
IMSyncRunStatus,
|
||||
OrganizationCandidateId,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull as HITLv1NodeData
|
||||
from core.workflow.nodes.human_input_v2.entities import Channel
|
||||
from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as HITLv2NodeData
|
||||
from fields.base import ResponseModel
|
||||
from fields.pagination import PaginationParamsMixin, PaginationResultMixin
|
||||
from fields.timestamp import Timestamp
|
||||
from libs.helper import EmailStr
|
||||
|
||||
|
||||
class _NoExtraModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _RequestModel(BaseModel):
|
||||
"""Base request model that forbids unknown fields while accepting JSON-native values."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class _MigrationInputModel(BaseModel):
|
||||
"""Forward-compatible migration input that ignores fields unknown to this backend version."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ContactListQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for listing contacts in the workspace directory."""
|
||||
|
||||
group: HumanInputContactType | None = Field(
|
||||
default=None,
|
||||
description="Optional contact type filter. None means all contacts.",
|
||||
)
|
||||
keyword: str | None = Field(default=None, description="Free-text search against contact name or email.")
|
||||
|
||||
|
||||
class ContactOptionsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for selecting contacts in workflow editors."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against selectable contact names.")
|
||||
|
||||
|
||||
class OrganizationCandidatesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching organization member candidates."""
|
||||
|
||||
keyword: str | None = Field(default=None, description="Free-text search against candidate name or email.")
|
||||
|
||||
|
||||
ExternalContactName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Display name shown in the contact directory.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactEmail = Annotated[
|
||||
EmailStr,
|
||||
Field(
|
||||
description="Primary email used for delivery and identity verification.",
|
||||
),
|
||||
]
|
||||
|
||||
ExternalContactAvatar = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description=(
|
||||
"Optional avatar file ID. Upload the avatar image first via "
|
||||
"`POST /console/api/files/upload`, then use the returned file id here."
|
||||
" Set to empty string for resetting to default avatar."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ExternalContactCreateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName
|
||||
email: ExternalContactEmail
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class ExternalContactUpdateRequest(_RequestModel):
|
||||
"""Request body for creating or updating one external contact."""
|
||||
|
||||
name: ExternalContactName | None = None
|
||||
email: ExternalContactEmail | None = None
|
||||
avatar: ExternalContactAvatar | None = None
|
||||
|
||||
|
||||
class IMBinding(BaseModel):
|
||||
id: IMBindingId = Field(description="Unique IM binding identifier.")
|
||||
provider: IMProvider = Field(description="Provider of the IM binding.")
|
||||
scope: IMBindingScope = Field(description="Scope of the IM binding.")
|
||||
|
||||
|
||||
class HumanInputContactSummary(BaseModel):
|
||||
"""A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class HumanInputContact(BaseModel):
|
||||
"""One contact entity returned by contact-related APIs."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact directory.")
|
||||
email: str | None = Field(default=None, description="Primary contact email if one exists.")
|
||||
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
|
||||
# the `im_bindings` field is always empty for EXTERNAL contacts
|
||||
im_bindings: list[IMBinding] = Field(
|
||||
default_factory=list[IMBinding],
|
||||
description=(
|
||||
"IM bindings that are bound to this contact. "
|
||||
"Currently, only one IM binding is supported. "
|
||||
"There is at most one IM binding per IM provider."
|
||||
),
|
||||
)
|
||||
|
||||
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
|
||||
|
||||
|
||||
class ContactOption(ResponseModel):
|
||||
"""Least-privilege contact projection returned to workflow editors."""
|
||||
|
||||
id: ContactId = Field(description="Unique contact identifier persisted in workflow recipient configuration.")
|
||||
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
|
||||
name: str = Field(description="Display name shown in the contact picker.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
|
||||
|
||||
class ExternalContactCreateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The created external contact.")
|
||||
|
||||
|
||||
class ExternalContactUpdateResponse(ResponseModel):
|
||||
contact: HumanInputContact = Field(description="The updated external contact. Fields are values after updating.")
|
||||
|
||||
|
||||
class OrganizationCandidate(ResponseModel):
|
||||
"""One organization member candidate that may become a platform contact."""
|
||||
|
||||
id: OrganizationCandidateId = Field(description="Organization candidate identifier.")
|
||||
name: str = Field(description="Display name shown in the candidate list.")
|
||||
email: str = Field(description="Primary organization email used for matching.")
|
||||
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
|
||||
|
||||
|
||||
class ListContactsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for contact list APIs."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts returned for the current page.")
|
||||
|
||||
|
||||
class ListContactOptionsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated editor-safe contact picker response."""
|
||||
|
||||
data: list[ContactOption] = Field(description="Selectable contacts returned for the current page.")
|
||||
|
||||
|
||||
class GetContactResponse(ResponseModel):
|
||||
"""Response body for one contact resolved in the current workspace scope."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact resolved as workspace, platform, or external.")
|
||||
|
||||
|
||||
class ListOrganizationCandidatesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for organization candidate search."""
|
||||
|
||||
data: list[OrganizationCandidate] = Field(
|
||||
description="Organization member candidates returned for the current page."
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsRequest(_RequestModel):
|
||||
"""Request body for adding one or more organization members as platform contacts."""
|
||||
|
||||
candidate_ids: list[OrganizationCandidateId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Organization candidate identifiers to project into the current workspace as platform contacts.",
|
||||
)
|
||||
|
||||
|
||||
class AddPlatformContactsResponse(ResponseModel):
|
||||
"""Response body for adding platform contacts."""
|
||||
|
||||
data: list[HumanInputContact] = Field(description="Contacts created by the current add operation.")
|
||||
|
||||
|
||||
class RemoveContactsRequest(_RequestModel):
|
||||
"""Request body for batch-removing platform or external contacts."""
|
||||
|
||||
contact_ids: list[ContactId] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Contact identifiers selected for removal from the contact directory surface.",
|
||||
)
|
||||
|
||||
|
||||
class RemoveContactsResponse(ResponseModel):
|
||||
"""Response body returned after batch-removing contacts."""
|
||||
|
||||
removed_contact_ids: list[ContactId] = Field(description="Contact identifiers removed by the current operation.")
|
||||
|
||||
|
||||
class _FeishuLarkIMIntegrationCredentialsBase(_RequestModel):
|
||||
"""Shared credential fields for Feishu and Lark integrations."""
|
||||
|
||||
app_id: str = Field(description="Feishu or Lark application identifier.")
|
||||
app_secret: str | PreserveOriginalValue = Field(description="Feishu or Lark application secret.")
|
||||
verification_token: str | PreserveOriginalValue | None = Field(
|
||||
default=None, description="Optional callback verification token."
|
||||
)
|
||||
encrypt_key: str | PreserveOriginalValue | None = Field(default=None, description="Optional callback encrypt key.")
|
||||
|
||||
|
||||
class FeishuIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
|
||||
"""Feishu integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.FEISHU] = Field(description="Discriminator for Feishu integration credentials.")
|
||||
|
||||
|
||||
class LarkIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
|
||||
"""Lark integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.LARK] = Field(description="Discriminator for Lark integration credentials.")
|
||||
|
||||
|
||||
class SlackIMIntegrationCredentials(_RequestModel):
|
||||
"""Slack integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.SLACK] = Field(description="Discriminator for Slack integration credentials.")
|
||||
client_id: str = Field(description="Slack OAuth client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(description="Slack OAuth client secret.")
|
||||
signing_secret: str | PreserveOriginalValue = Field(description="Slack signing secret used to verify callbacks.")
|
||||
bot_token: str | PreserveOriginalValue = Field(
|
||||
description="Slack bot token used for API calls and message delivery."
|
||||
)
|
||||
|
||||
|
||||
class DingTalkIMIntegrationCredentials(_RequestModel):
|
||||
"""DingTalk integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.DING_TALK] = Field(description="Discriminator for DingTalk integration credentials.")
|
||||
client_id: str = Field(description="DingTalk application client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(
|
||||
description="DingTalk application client secret. This field will be masked in response."
|
||||
)
|
||||
|
||||
|
||||
class MSTeamsIMIntegrationCredentials(_RequestModel):
|
||||
"""Microsoft Teams integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.MS_TEAMS] = Field(
|
||||
description="Discriminator for Microsoft Teams integration credentials."
|
||||
)
|
||||
tenant_id: str = Field(description="Microsoft Entra tenant identifier.")
|
||||
client_id: str = Field(description="Microsoft Teams application client identifier.")
|
||||
client_secret: str | PreserveOriginalValue = Field(
|
||||
description="Microsoft Teams application client secret. This field will be masked in response"
|
||||
)
|
||||
|
||||
|
||||
class WeComIMIntegrationCredentials(_RequestModel):
|
||||
"""WeCom integration credentials used by organization-level IM setup."""
|
||||
|
||||
provider: Literal[IMProvider.WE_COM] = Field(description="Discriminator for WeCom integration credentials.")
|
||||
corp_id: str = Field(description="WeCom corporation identifier.")
|
||||
agent_id: str = Field(description="WeCom agent identifier.")
|
||||
secret: str | PreserveOriginalValue = Field(
|
||||
description="WeCom application secret. This field will be masked in response"
|
||||
)
|
||||
|
||||
|
||||
IMIntegrationCredentials = Annotated[
|
||||
FeishuIMIntegrationCredentials
|
||||
| LarkIMIntegrationCredentials
|
||||
| SlackIMIntegrationCredentials
|
||||
| DingTalkIMIntegrationCredentials
|
||||
| MSTeamsIMIntegrationCredentials
|
||||
| WeComIMIntegrationCredentials,
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class _IMIntegrationRequest(_RequestModel):
|
||||
"""Internal shared body for IM integration write/test operations."""
|
||||
|
||||
credentials: IMIntegrationCredentials = Field(description="Provider-specific IM integration credentials.")
|
||||
|
||||
|
||||
class UpdateIMIntegrationRequest(_IMIntegrationRequest):
|
||||
"""Request body for creating or updating one IM integration."""
|
||||
|
||||
expected_integration_id: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
description="Current integration identifier used with expected_config_version for compare-and-swap.",
|
||||
)
|
||||
expected_config_version: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Current integration revision used with expected_integration_id for compare-and-swap.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_cas_token(self) -> Self:
|
||||
has_integration_id = self.expected_integration_id is not None
|
||||
has_config_version = self.expected_config_version is not None
|
||||
if has_integration_id != has_config_version:
|
||||
raise ValueError("expected_integration_id and expected_config_version must be provided together")
|
||||
return self
|
||||
|
||||
|
||||
class DeleteIMIntegrationQuery(_NoExtraModel):
|
||||
"""CAS token required when deleting the current IM integration."""
|
||||
|
||||
expected_integration_id: str = Field(min_length=1, description="Current integration identifier.")
|
||||
expected_config_version: int = Field(ge=1, description="Current integration revision.")
|
||||
|
||||
|
||||
class TestIMIntegrationRequest(_IMIntegrationRequest):
|
||||
"""Request body for testing one IM integration."""
|
||||
|
||||
|
||||
class IMIntegration(ResponseModel):
|
||||
"""One organization-level IM integration snapshot."""
|
||||
|
||||
provider: IMProvider | None = Field(
|
||||
default=None,
|
||||
description="Configured IM provider. None is allowed when the integration is not configured.",
|
||||
)
|
||||
status: IMIntegrationStatus = Field(description="Current integration connectivity state.")
|
||||
callback_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Callback URL expected by the provider. "
|
||||
"None if the current deployment uses persistence connections for receive events."
|
||||
),
|
||||
)
|
||||
permission_hint: str | None = Field(default=None, description="Operator-facing hint about permission issues.")
|
||||
configured_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the integration was created."
|
||||
)
|
||||
updated_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the integration was last updated."
|
||||
)
|
||||
integration_id: str | None = Field(
|
||||
default=None,
|
||||
description="Stable integration identifier. None when no integration is configured.",
|
||||
)
|
||||
config_version: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Monotonic configuration revision. None when no integration is configured.",
|
||||
)
|
||||
|
||||
|
||||
class GetIMIntegrationResponse(ResponseModel):
|
||||
"""Response body carrying one IM integration snapshot."""
|
||||
|
||||
integration: IMIntegration = Field(description="Current organization-level IM integration snapshot.")
|
||||
|
||||
|
||||
class UpdateIMIntegrationResponse(ResponseModel):
|
||||
"""Response body returned after updating one IM integration."""
|
||||
|
||||
integration: IMIntegration = Field(description="Saved organization-level IM integration snapshot.")
|
||||
|
||||
|
||||
class TestIMIntegrationResponse(ResponseModel):
|
||||
"""Response body returned by IM integration test APIs."""
|
||||
|
||||
status: IMIntegrationStatus = Field(description="Integration status mapped from the test result.")
|
||||
message: str = Field(description="Human-readable explanation of the test result.")
|
||||
|
||||
|
||||
class IMSyncRunResultCounts(ResponseModel):
|
||||
"""Aggregate result counts for one IM sync run."""
|
||||
|
||||
added: int = Field(description="Number of entries newly matched and bound.")
|
||||
not_matched: int = Field(description="Number of entries that could not be matched.")
|
||||
failed: int = Field(description="Number of entries that failed to reconcile.")
|
||||
removed: int = Field(description="Number of entries whose prior binding was removed.")
|
||||
skipped: int = Field(description="Number of entries intentionally skipped.")
|
||||
|
||||
|
||||
class IMSyncRun(ResponseModel):
|
||||
"""One IM sync run snapshot.
|
||||
|
||||
The latest-only UI displays ``finished_at`` as the explicit sync time. The
|
||||
transport contract intentionally does not expose a ``started_by`` actor.
|
||||
"""
|
||||
|
||||
id: IMSyncRunId = Field(description="Unique sync run identifier.")
|
||||
status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.")
|
||||
started_at: Timestamp | None = Field(
|
||||
default=None, description="Unix timestamp in milliseconds when the sync run started."
|
||||
)
|
||||
finished_at: Timestamp | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Unix timestamp in milliseconds when the sync run finished. "
|
||||
"This is the sync time displayed by the latest-only UI and is None while the run is unfinished."
|
||||
),
|
||||
)
|
||||
error_message: str | None = Field(
|
||||
default=None,
|
||||
description="Terminal error message. Present only when the sync run status is `failed`.",
|
||||
)
|
||||
result_counts: IMSyncRunResultCounts = Field(
|
||||
description="Aggregate reconciliation counts for the current run snapshot.",
|
||||
)
|
||||
provider: IMProvider = Field(description="IM provider associated with the sync run.")
|
||||
integration_id: str = Field(description="Integration identifier captured when the sync run was created.")
|
||||
integration_config_version: int = Field(
|
||||
ge=1,
|
||||
description="Integration configuration revision captured when the sync run was created.",
|
||||
)
|
||||
|
||||
|
||||
class CreateIMSyncRunResponse(ResponseModel):
|
||||
"""Response body returned after creating one sync run."""
|
||||
|
||||
run: IMSyncRun = Field(description="Newly created sync run snapshot.")
|
||||
|
||||
|
||||
class IMDirectoryEntry(_RequestModel):
|
||||
"""Normalized provider-side account observed during an IM sync run.
|
||||
|
||||
The entry is run-scoped input to identity and binding reconciliation. It does
|
||||
not represent a stable Dify identity and must not be referenced by bindings
|
||||
or runtime authorization. Sync results may retain a snapshot for display,
|
||||
diagnostics, and audit; durable references use IMIdentity or IMBinding IDs.
|
||||
"""
|
||||
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMIdentitySnapshot(_RequestModel):
|
||||
"""Last known persistent IM identity state retained by a sync result."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider_user_id: str
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class IMSyncResultAdded(BaseModel):
|
||||
type: Literal[IMSyncResultType.ADDED] = IMSyncResultType.ADDED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
entry: IMDirectoryEntry = Field(description="Provider directory entry observed during the current sync run.")
|
||||
|
||||
|
||||
class IMSyncResultRemoved(BaseModel):
|
||||
type: Literal[IMSyncResultType.REMOVED] = IMSyncResultType.REMOVED
|
||||
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
last_known_identity: IMIdentitySnapshot = Field(
|
||||
description="Last known persistent IM identity state before its binding was removed."
|
||||
)
|
||||
reason: IMSyncRemovalReason = Field(description="Reason the existing IM binding was removed.")
|
||||
|
||||
|
||||
class IMSyncResultFailed(BaseModel):
|
||||
type: Literal[IMSyncResultType.FAILED] = IMSyncResultType.FAILED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before this reconciliation failure, if available."
|
||||
)
|
||||
reason: str = Field(description="Reason the binding failed to sync.")
|
||||
|
||||
|
||||
class IMSyncResultSkipped(BaseModel):
|
||||
type: Literal[IMSyncResultType.SKIPPED] = IMSyncResultType.SKIPPED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry observed before reconciliation was skipped, if available."
|
||||
)
|
||||
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
|
||||
|
||||
|
||||
class IMSyncResultNotMatched(BaseModel):
|
||||
type: Literal[IMSyncResultType.NOT_MATCHED] = IMSyncResultType.NOT_MATCHED
|
||||
|
||||
entry: IMDirectoryEntry | None = Field(
|
||||
None, description="Provider directory entry that could not be matched, if available."
|
||||
)
|
||||
|
||||
|
||||
IMSyncResult = Annotated[
|
||||
Union[
|
||||
IMSyncResultAdded,
|
||||
IMSyncResultRemoved,
|
||||
IMSyncResultFailed,
|
||||
IMSyncResultNotMatched,
|
||||
IMSyncResultSkipped,
|
||||
],
|
||||
Discriminator("type"),
|
||||
]
|
||||
|
||||
|
||||
class IMSyncResultItem(ResponseModel):
|
||||
"""One paginated reconciliation result entry for the latest sync run."""
|
||||
|
||||
# The current implementation does not return IM binding status for other IM providers.
|
||||
# According to the design, we should return IM binding status for all configured IM providers.
|
||||
# However, this version allows only one configured IM provider, so this model excludes
|
||||
# the IM binding status for other IM providers.
|
||||
|
||||
id: str = Field(description="Unique synchronization result identifier.")
|
||||
result: IMSyncResult = Field(description="Result bucket this entry belongs to.")
|
||||
|
||||
|
||||
class GetLatestIMSyncRunResponse(ResponseModel):
|
||||
"""Response body for reading the latest IM sync run summary."""
|
||||
|
||||
run: IMSyncRun = Field(description="Latest sync run summary.")
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for reading paginated latest-run results."""
|
||||
|
||||
result: IMSyncResultType = Field(
|
||||
...,
|
||||
description=(
|
||||
"Required result bucket to paginate from the latest sync run. "
|
||||
"There is no `all` bucket or unfiltered results mode."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ListLatestIMSyncRunResultsResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Page-based latest-run results without cursor state or a repeated run summary."""
|
||||
|
||||
data: list[IMSyncResultItem] = Field(
|
||||
description="Result entries returned with page, limit, and total metadata for the selected bucket."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesQuery(PaginationParamsMixin, _NoExtraModel):
|
||||
"""Query params for searching synced IM identities."""
|
||||
|
||||
keyword: str | None = Field(
|
||||
default=None,
|
||||
description="Free-text search against identity display name, email, or provider user ID.",
|
||||
)
|
||||
|
||||
|
||||
class IMIdentity(ResponseModel):
|
||||
"""One synced IM identity that may be bound or overridden."""
|
||||
|
||||
id: IMIdentityId = Field(description="Internal IM identity record identifier.")
|
||||
provider: IMProvider = Field(description="IM provider that owns this identity.")
|
||||
provider_user_id: str = Field(description="Provider-side user identifier.")
|
||||
display_name: str | None = Field(default=None, description="Display name returned by the provider.")
|
||||
email: str | None = Field(default=None, description="Email returned by the provider, if any.")
|
||||
binding_status: IMIdentityBindingStatus = Field(
|
||||
description="Whether this IM identity is currently bound to a contact."
|
||||
)
|
||||
|
||||
|
||||
class ListIMIdentitiesResponse(PaginationResultMixin, ResponseModel):
|
||||
"""Paginated response body for synced IM identity search."""
|
||||
|
||||
data: list[IMIdentity] = Field(description="IM identities returned for the current page.")
|
||||
|
||||
|
||||
class SetContactIMOverrideRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class SetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after setting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is applied.")
|
||||
|
||||
|
||||
class ResetContactIMOverrideResponse(ResponseModel):
|
||||
"""Response body returned after resetting one contact IM override."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the override is cleared.")
|
||||
|
||||
|
||||
class CreateIMBindingRequest(_RequestModel):
|
||||
"""Request body for setting one workspace-scoped IM override."""
|
||||
|
||||
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
|
||||
|
||||
|
||||
class CreateIMBindingResponse(ResponseModel):
|
||||
"""Response body returned after binding one IM identity to the workspace."""
|
||||
|
||||
contact: HumanInputContact = Field(description="Contact snapshot after the IM identity is bound.")
|
||||
|
||||
|
||||
class DeleteIMBindingQuery(_RequestModel):
|
||||
binding_id: IMBindingId = Field(description="IM binding to unbind.")
|
||||
|
||||
|
||||
class DeleteIMBindingResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class MessageTemplateTestRequest(_RequestModel):
|
||||
"""Request body for sending one message-template test notification."""
|
||||
|
||||
channel: Channel = Field(description="Target debug delivery channel used for the test send.")
|
||||
inputs: dict[str, JsonValue] = Field(
|
||||
default_factory=dict,
|
||||
description="Variable values used when rendering the message template preview.",
|
||||
)
|
||||
|
||||
|
||||
class MessageTemplateTestResponse(ResponseModel):
|
||||
"""Response body returned after one message-template test send."""
|
||||
|
||||
|
||||
class FormAccessRequestResponse(ResponseModel):
|
||||
"""Response body returned after creating one OTP challenge."""
|
||||
|
||||
expires_in_seconds: int = Field(description="Seconds until the current OTP challenge expires.")
|
||||
resend_after_seconds: int = Field(description="Seconds until another OTP challenge may be requested.")
|
||||
challenge_token: str = Field(description="The token used to complete the OTP challenge.")
|
||||
|
||||
|
||||
class FormDefinitionResponse(ResponseModel):
|
||||
"""Response body containing a resolved human-input form definition."""
|
||||
|
||||
form_content: str | None = Field(default=None, description="Rendered form body shown to the approver.")
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list, description="Resolved form input definitions.")
|
||||
resolved_default_values: dict[str, str] = Field(
|
||||
default_factory=dict,
|
||||
description="Default values after variable resolution and stringification.",
|
||||
)
|
||||
user_actions: list[UserActionConfig] = Field(
|
||||
default_factory=list,
|
||||
description="Action buttons that can complete the form.",
|
||||
)
|
||||
expiration_time: int = Field(description="Unix timestamp when the current form expires.")
|
||||
|
||||
|
||||
class ServiceFormQuery(_NoExtraModel):
|
||||
"""Query params for reading one service-api human-input form."""
|
||||
|
||||
user: str = Field(min_length=1, description="End-user identifier used to scope the service API request.")
|
||||
|
||||
|
||||
class BatchGetContactsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="List of contact IDs to retrieve.")
|
||||
|
||||
|
||||
class BatchGetContactsResponse(ResponseModel):
|
||||
data: list[HumanInputContactSummary] = Field(..., description="List of retrieved human input contacts.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsQuery(_NoExtraModel):
|
||||
contact_ids: list[ContactId] = Field(..., description="Contact IDs persisted in workflow recipient configuration.")
|
||||
|
||||
|
||||
class BatchGetContactOptionsResponse(ResponseModel):
|
||||
data: list[ContactOption] = Field(..., description="Selectable contacts resolved in request order.")
|
||||
|
||||
|
||||
class HumanInputV2FormSubmitRequest(_RequestModel):
|
||||
"""Public Human Input v2 submit payload, independent from the v1 form contract."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
|
||||
action: str = Field(description="Identifier of the selected Human Input v2 action.")
|
||||
challenge_token: str | None = Field(
|
||||
default=None,
|
||||
description="OTP challenge token returned by the Human Input v2 access-request endpoint.",
|
||||
)
|
||||
otp_code: str | None = Field(
|
||||
default=None,
|
||||
description="OTP code required when the current Human Input v2 approver uses email proof.",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_complete_email_proof(self) -> Self:
|
||||
has_challenge_token = self.challenge_token is not None
|
||||
has_otp_code = self.otp_code is not None
|
||||
if has_challenge_token != has_otp_code:
|
||||
raise ValueError("challenge_token and otp_code must be provided together")
|
||||
return self
|
||||
|
||||
|
||||
class HumanInputV2ServiceFormSubmitRequest(_RequestModel):
|
||||
"""Trusted Service API submit payload without public-web OTP proof fields."""
|
||||
|
||||
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
|
||||
action: str = Field(description="Identifier of the selected Human Input v2 action.")
|
||||
user: str = Field(min_length=1, description="End-user identifier scoped to the current app token.")
|
||||
|
||||
|
||||
class FormUploadTokenResponse(ResponseModel):
|
||||
"""Response body returned when issuing a Human Input v2 upload token."""
|
||||
|
||||
upload_token: str
|
||||
expires_at: int
|
||||
|
||||
|
||||
class FormSubmitResponse(ResponseModel):
|
||||
"""Empty response body returned after a Human Input v2 form submission."""
|
||||
|
||||
|
||||
# =================== Node migration related entities ===================
|
||||
|
||||
|
||||
class LegacyHITLv1NodeData(HITLv1NodeData):
|
||||
"""Legacy Human Input node data accepted by the v1-to-v2 migration helper.
|
||||
|
||||
Missing versions use the historical v1 default. Any explicit value other
|
||||
than the string ``"1"`` is rejected before migration.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
# Keep the mutable parent field type for static substitutability while
|
||||
# preserving the literal transport schema and runtime validation.
|
||||
version: str = Field(
|
||||
default="1",
|
||||
description=(
|
||||
'Legacy Human Input node version. Missing values default to "1"; '
|
||||
'any explicit value other than the string "1" is rejected.'
|
||||
),
|
||||
json_schema_extra={"const": "1"},
|
||||
)
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version(cls, value: str) -> str:
|
||||
if value != "1":
|
||||
raise ValueError('version must be "1"')
|
||||
return value
|
||||
|
||||
|
||||
class NodeDataMigrationInput(_MigrationInputModel):
|
||||
"""One legacy node submitted through the frontend migration adapter boundary."""
|
||||
|
||||
node_id: str = Field(
|
||||
..., description="The identifier of node to migrate. Used to associate between request and response"
|
||||
)
|
||||
node_data: LegacyHITLv1NodeData = Field(..., description="The legacy Human Input node data to migrate.")
|
||||
|
||||
|
||||
class NodeDataMigrationPayload(_MigrationInputModel):
|
||||
"""Complete legacy-node batch submitted for one migration attempt."""
|
||||
|
||||
nodes: list[NodeDataMigrationInput] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_node_ids(self) -> Self:
|
||||
node_ids = [node.node_id for node in self.nodes]
|
||||
if len(node_ids) != len(set(node_ids)):
|
||||
raise ValueError("node_id must be unique within one migration request")
|
||||
return self
|
||||
|
||||
|
||||
class NodeDataMigrationResult(ResponseModel):
|
||||
"""One converted node returned with its frontend correlation identifier."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the migrated node.")
|
||||
node_data: HITLv2NodeData = Field(description="The complete converted Human Input v2 node data.")
|
||||
|
||||
|
||||
class NodeDataMigrationResponse(ResponseModel):
|
||||
"""Successful all-node conversion response."""
|
||||
|
||||
data: list[NodeDataMigrationResult]
|
||||
|
||||
|
||||
NodeDataMigrationBlockerCode = Literal[
|
||||
"unsupported-version",
|
||||
"configured-disabled-method",
|
||||
"unsupported-delivery-method",
|
||||
"invalid-email-configuration",
|
||||
"invalid-email",
|
||||
"unresolved-member",
|
||||
"conflicting-email-templates",
|
||||
"missing-recipients",
|
||||
]
|
||||
|
||||
|
||||
class NodeDataMigrationBlocker(ResponseModel):
|
||||
"""Stable node-scoped reason why the backend cannot produce lossless v2 data."""
|
||||
|
||||
node_id: str = Field(description="The identifier of the node that failed migration.")
|
||||
node_title: str = Field(description="The node title used for actionable frontend feedback.")
|
||||
code: NodeDataMigrationBlockerCode = Field(description="Machine-readable migration blocker code.")
|
||||
method_id: str | None = Field(default=None, description="Legacy delivery method related to the blocker.")
|
||||
value: str | None = Field(default=None, description="Safe legacy value related to the blocker.")
|
||||
|
||||
|
||||
class NodeDataMigrationFailureResponse(ResponseModel):
|
||||
"""Whole-batch failure response without partial converted node data."""
|
||||
|
||||
code: Literal["hitl_node_data_migration_failure"] = "hitl_node_data_migration_failure"
|
||||
message: str = Field(..., description="overall error messages")
|
||||
status: Literal[HTTPStatus.BAD_REQUEST] = HTTPStatus.BAD_REQUEST
|
||||
blockers: list[NodeDataMigrationBlocker] = Field(
|
||||
..., description="Node-scoped blockers that caused the whole batch to fail."
|
||||
)
|
||||
|
||||
|
||||
# =================== EmailProvider related entities ===================
|
||||
|
||||
|
||||
class PreserveOriginalValue(_RequestModel):
|
||||
tag: Literal["preserve_original_value"] = "preserve_original_value"
|
||||
|
||||
|
||||
class ResendProviderUpdateConfig(_RequestModel):
|
||||
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
|
||||
|
||||
api_key: str | PreserveOriginalValue = Field(
|
||||
...,
|
||||
description=(
|
||||
"Resend API key. "
|
||||
"Setting this to `PreserveOriginalValue` while updating will preserve the previously set credential."
|
||||
),
|
||||
)
|
||||
sender_email: str = Field(
|
||||
..., description="The email address shown as the sender. Its domain must be verified in Resend."
|
||||
)
|
||||
|
||||
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
|
||||
|
||||
|
||||
class ResendProviderConfigResponse(ResponseModel):
|
||||
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
|
||||
api_key_configured: bool = Field(description="Whether a Resend API key has been configured.")
|
||||
sender_email: str = Field(description="The email address shown as the sender.")
|
||||
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
|
||||
|
||||
|
||||
EmailProviderUpdateConfig = ResendProviderUpdateConfig
|
||||
EmailProviderConfigResponse = ResendProviderConfigResponse
|
||||
|
||||
|
||||
class GetEmailProviderResponse(ResponseModel):
|
||||
provider_config: EmailProviderConfigResponse | None = Field(
|
||||
...,
|
||||
description="The current email provider configuration. `None` if not set.",
|
||||
)
|
||||
|
||||
|
||||
class SetEmailProviderRequest(_RequestModel):
|
||||
provider_config: EmailProviderUpdateConfig = Field(..., description="Email provider configuration update.")
|
||||
|
||||
|
||||
class SetEmailProviderResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
class TestEmailProviderConfigRequest(_RequestModel):
|
||||
pass
|
||||
|
||||
|
||||
class TestEmailProviderConfigResponse(ResponseModel):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AddPlatformContactsRequest",
|
||||
"AddPlatformContactsResponse",
|
||||
"BatchGetContactOptionsQuery",
|
||||
"BatchGetContactOptionsResponse",
|
||||
"ContactListQuery",
|
||||
"ContactOption",
|
||||
"ContactOptionsQuery",
|
||||
"CreateIMSyncRunResponse",
|
||||
"DeleteIMIntegrationQuery",
|
||||
"DingTalkIMIntegrationCredentials",
|
||||
"EmailProviderConfigResponse",
|
||||
"EmailProviderType",
|
||||
"EmailProviderUpdateConfig",
|
||||
"ExternalContactCreateRequest",
|
||||
"ExternalContactUpdateRequest",
|
||||
"FeishuIMIntegrationCredentials",
|
||||
"FormAccessRequestResponse",
|
||||
"FormDefinitionResponse",
|
||||
"FormSubmitResponse",
|
||||
"FormUploadTokenResponse",
|
||||
"GetContactResponse",
|
||||
"GetEmailProviderResponse",
|
||||
"GetIMIntegrationResponse",
|
||||
"GetLatestIMSyncRunResponse",
|
||||
"HumanInputContact",
|
||||
"HumanInputContactType",
|
||||
"HumanInputV2FormSubmitRequest",
|
||||
"HumanInputV2ServiceFormSubmitRequest",
|
||||
"IMIdentity",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMIntegration",
|
||||
"IMIntegrationCredentials",
|
||||
"IMIntegrationStatus",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultItem",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRun",
|
||||
"IMSyncRunResultCounts",
|
||||
"IMSyncRunStatus",
|
||||
"LarkIMIntegrationCredentials",
|
||||
"ListContactOptionsResponse",
|
||||
"ListContactsResponse",
|
||||
"ListIMIdentitiesQuery",
|
||||
"ListIMIdentitiesResponse",
|
||||
"ListLatestIMSyncRunResultsQuery",
|
||||
"ListLatestIMSyncRunResultsResponse",
|
||||
"ListOrganizationCandidatesResponse",
|
||||
"MSTeamsIMIntegrationCredentials",
|
||||
"MessageTemplateTestRequest",
|
||||
"MessageTemplateTestResponse",
|
||||
"NodeDataMigrationFailureResponse",
|
||||
"NodeDataMigrationPayload",
|
||||
"NodeDataMigrationResponse",
|
||||
"OrganizationCandidate",
|
||||
"OrganizationCandidatesQuery",
|
||||
"PreserveOriginalValue",
|
||||
"RemoveContactsRequest",
|
||||
"RemoveContactsResponse",
|
||||
"ResendProviderConfigResponse",
|
||||
"ResendProviderUpdateConfig",
|
||||
"ResetContactIMOverrideResponse",
|
||||
"ServiceFormQuery",
|
||||
"SetContactIMOverrideRequest",
|
||||
"SetContactIMOverrideResponse",
|
||||
"SetEmailProviderRequest",
|
||||
"SetEmailProviderResponse",
|
||||
"SlackIMIntegrationCredentials",
|
||||
"TestIMIntegrationRequest",
|
||||
"TestIMIntegrationResponse",
|
||||
"UpdateIMIntegrationRequest",
|
||||
"UpdateIMIntegrationResponse",
|
||||
"WeComIMIntegrationCredentials",
|
||||
]
|
||||
@@ -76,6 +76,7 @@ from .app import (
|
||||
workflow_app_log,
|
||||
workflow_comment,
|
||||
workflow_draft_variable,
|
||||
workflow_human_input_v2,
|
||||
workflow_node_output_inspector,
|
||||
workflow_run,
|
||||
workflow_statistic,
|
||||
@@ -138,6 +139,7 @@ from .workspace import (
|
||||
account,
|
||||
agent_providers,
|
||||
endpoint,
|
||||
human_input,
|
||||
load_balancing_config,
|
||||
members,
|
||||
model_providers,
|
||||
@@ -194,6 +196,7 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"generator",
|
||||
"hit_testing",
|
||||
"human_input",
|
||||
"human_input_form",
|
||||
"init_validate",
|
||||
"installed_app",
|
||||
@@ -241,6 +244,7 @@ __all__ = [
|
||||
"workflow_app_log",
|
||||
"workflow_comment",
|
||||
"workflow_draft_variable",
|
||||
"workflow_human_input_v2",
|
||||
"workflow_node_output_inspector",
|
||||
"workflow_run",
|
||||
"workflow_run_archive",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Draft Human Input v2 workflow controller stubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
MessageTemplateTestRequest,
|
||||
MessageTemplateTestResponse,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import AppMode
|
||||
|
||||
from .wraps import get_app_model
|
||||
|
||||
register_schema_models(console_ns, MessageTemplateTestRequest)
|
||||
register_response_schema_models(console_ns, MessageTemplateTestResponse)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 draft stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/human-input/nodes/<string:node_id>/message-template/test")
|
||||
class WorkflowDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model, node_id: str):
|
||||
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/message-template/test"
|
||||
)
|
||||
class AdvancedChatDraftMessageTemplateTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model, node_id: str):
|
||||
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
@@ -0,0 +1,541 @@
|
||||
"""Workspace-level Human Input v2 controller stubs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
AddPlatformContactsRequest,
|
||||
AddPlatformContactsResponse,
|
||||
BatchGetContactOptionsQuery,
|
||||
BatchGetContactOptionsResponse,
|
||||
BatchGetContactsQuery,
|
||||
BatchGetContactsResponse,
|
||||
ContactListQuery,
|
||||
ContactOption,
|
||||
ContactOptionsQuery,
|
||||
CreateIMBindingRequest,
|
||||
CreateIMBindingResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
DeleteIMBindingQuery,
|
||||
DeleteIMBindingResponse,
|
||||
DeleteIMIntegrationQuery,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateRequest,
|
||||
ExternalContactUpdateResponse,
|
||||
GetContactResponse,
|
||||
GetEmailProviderResponse,
|
||||
GetIMIntegrationResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
HumanInputContact,
|
||||
HumanInputContactType,
|
||||
IMIntegrationStatus,
|
||||
IMProvider,
|
||||
IMSyncResultType,
|
||||
IMSyncRunStatus,
|
||||
ListContactOptionsResponse,
|
||||
ListContactsResponse,
|
||||
ListIMIdentitiesQuery,
|
||||
ListIMIdentitiesResponse,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
NodeDataMigrationPayload,
|
||||
NodeDataMigrationResponse,
|
||||
OrganizationCandidatesQuery,
|
||||
RemoveContactsRequest,
|
||||
RemoveContactsResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideRequest,
|
||||
SetContactIMOverrideResponse,
|
||||
SetEmailProviderRequest,
|
||||
SetEmailProviderResponse,
|
||||
TestIMIntegrationRequest,
|
||||
TestIMIntegrationResponse,
|
||||
UpdateIMIntegrationRequest,
|
||||
UpdateIMIntegrationResponse,
|
||||
)
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
is_admin_or_owner_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
)
|
||||
from libs.login import login_required
|
||||
|
||||
register_enum_models(
|
||||
console_ns,
|
||||
HumanInputContactType,
|
||||
IMIntegrationStatus,
|
||||
IMSyncRunStatus,
|
||||
IMSyncResultType,
|
||||
IMProvider,
|
||||
)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
ContactListQuery,
|
||||
ContactOptionsQuery,
|
||||
BatchGetContactOptionsQuery,
|
||||
OrganizationCandidatesQuery,
|
||||
AddPlatformContactsRequest,
|
||||
ExternalContactCreateRequest,
|
||||
ExternalContactUpdateRequest,
|
||||
RemoveContactsRequest,
|
||||
UpdateIMIntegrationRequest,
|
||||
DeleteIMIntegrationQuery,
|
||||
TestIMIntegrationRequest,
|
||||
ListIMIdentitiesQuery,
|
||||
ListLatestIMSyncRunResultsQuery,
|
||||
SetContactIMOverrideRequest,
|
||||
CreateIMBindingRequest,
|
||||
NodeDataMigrationPayload,
|
||||
SetEmailProviderRequest,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
HumanInputContact,
|
||||
ContactOption,
|
||||
GetContactResponse,
|
||||
ExternalContactCreateResponse,
|
||||
ExternalContactUpdateResponse,
|
||||
AddPlatformContactsResponse,
|
||||
ListContactsResponse,
|
||||
ListContactOptionsResponse,
|
||||
BatchGetContactOptionsResponse,
|
||||
RemoveContactsResponse,
|
||||
ListIMIdentitiesResponse,
|
||||
GetIMIntegrationResponse,
|
||||
UpdateIMIntegrationResponse,
|
||||
TestIMIntegrationResponse,
|
||||
CreateIMSyncRunResponse,
|
||||
GetLatestIMSyncRunResponse,
|
||||
ListLatestIMSyncRunResultsResponse,
|
||||
ListOrganizationCandidatesResponse,
|
||||
ResetContactIMOverrideResponse,
|
||||
SetContactIMOverrideResponse,
|
||||
CreateIMBindingResponse,
|
||||
DeleteIMBindingResponse,
|
||||
BatchGetContactsResponse,
|
||||
NodeDataMigrationResponse,
|
||||
NodeDataMigrationFailureResponse,
|
||||
GetEmailProviderResponse,
|
||||
SetEmailProviderResponse,
|
||||
)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts")
|
||||
class WorkspaceContactsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ContactListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ContactListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>")
|
||||
class WorkspaceContactApi(Resource):
|
||||
"""Read one contact only when it resolves in the current workspace scope."""
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[GetContactResponse.__name__])
|
||||
@console_ns.response(404, "Contact not found or absent in the current workspace")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, contact_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contact-options")
|
||||
class WorkspaceContactOptionsApi(Resource):
|
||||
"""Search the current workspace's selectable Contact projection for workflow editors."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ContactOptionsQuery),
|
||||
description=(
|
||||
"List editor-safe Contact options for static recipient selection. "
|
||||
"The projection omits email, IM bindings, and management metadata; contacts that resolve as ABSENT "
|
||||
"or are otherwise unavailable in the current workspace are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ContactOptionsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/organization-candidates")
|
||||
class WorkspaceOrganizationCandidatesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(OrganizationCandidatesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListOrganizationCandidatesResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
OrganizationCandidatesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/platform")
|
||||
class WorkspacePlatformContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AddPlatformContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AddPlatformContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
AddPlatformContactsRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/external")
|
||||
class WorkspaceExternalContactsApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactCreateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactCreateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
ExternalContactCreateRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/external/<uuid:contact_id>")
|
||||
class WorkspaceExternalContactApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ExternalContactUpdateRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ExternalContactUpdateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def patch(self, tenant_id: str, contact_id: str):
|
||||
ExternalContactUpdateRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/remove")
|
||||
class WorkspaceContactsRemoveApi(Resource):
|
||||
@console_ns.expect(console_ns.models[RemoveContactsRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[RemoveContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
RemoveContactsRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-integration")
|
||||
class WorkspaceIMIntegrationApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[GetIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.expect(console_ns.models[UpdateIMIntegrationRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[UpdateIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str):
|
||||
UpdateIMIntegrationRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(params=query_params_from_model(DeleteIMIntegrationQuery))
|
||||
@console_ns.response(204, "IM integration deleted successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str):
|
||||
query_params_from_request(DeleteIMIntegrationQuery)
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-integration/test")
|
||||
class WorkspaceIMIntegrationTestApi(Resource):
|
||||
@console_ns.expect(console_ns.models[TestIMIntegrationRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[TestIMIntegrationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
TestIMIntegrationRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs")
|
||||
class WorkspaceIMSyncRunsApi(Resource):
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMSyncRunResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest")
|
||||
class WorkspaceLatestIMSyncRunApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; "
|
||||
"the response does not include started_by."
|
||||
)
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest/results")
|
||||
class WorkspaceLatestIMSyncRunResultsApi(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(ListLatestIMSyncRunResultsQuery),
|
||||
description=(
|
||||
"Return one required result bucket from the latest IM sync run using page and limit pagination. "
|
||||
"There is no all filter; the response contains page, limit, and total metadata without a run summary."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ListLatestIMSyncRunResultsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ListLatestIMSyncRunResultsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/im-identities")
|
||||
class WorkspaceIMIdentitiesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(ListIMIdentitiesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[ListIMIdentitiesResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
ListIMIdentitiesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-override")
|
||||
class WorkspaceContactIMOverrideApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set or reset the IM override for a contact. "
|
||||
"This endpoint is used to override the IM identity for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[SetContactIMOverrideRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SetContactIMOverrideResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, contact_id: str):
|
||||
# This API only works in EE.
|
||||
SetContactIMOverrideRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Reset the IM override for a contact. "
|
||||
"This endpoint is used to clear the IM identity override for a contact in the workspace."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[ResetContactIMOverrideResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: str):
|
||||
# This API only works in EE.
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-bindings")
|
||||
class WorkspaceContactIMBindingsApi(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Set an IM binding for a contact. Used for binding an IM identity to a contact. "
|
||||
"This endpoint is not used for creating workspace IM override. "
|
||||
"For that purpose, use WorkspaceContactIMOverrideApi.put instead."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[CreateIMBindingRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[CreateIMBindingResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, contact_id: str):
|
||||
CreateIMBindingRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[DeleteIMBindingResponse.__name__])
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(DeleteIMBindingQuery),
|
||||
description=(
|
||||
"Delete an IM binding for a contact. Used for removing contact IM binding information. "
|
||||
"This endpoint is not used for resetting workspace IM override. For that purpose, use "
|
||||
"WorkspaceContactIMOverrideApi.delete instead."
|
||||
),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, contact_id: str):
|
||||
query_params_from_request(DeleteIMBindingQuery)
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contacts/batch")
|
||||
class BatchGetContactsAPI(Resource):
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactsQuery),
|
||||
description=(
|
||||
"Admin-only batch lookup for Contact management clients. "
|
||||
"Workflow editors must use the editor-safe contact-options/batch projection."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query_params_from_request(BatchGetContactsQuery, list_fields=("contact_ids",))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/contact-options/batch")
|
||||
class BatchGetContactOptionsAPI(Resource):
|
||||
"""Resolve persisted Contact IDs through the same editor-safe selection projection."""
|
||||
|
||||
@console_ns.doc(
|
||||
params=query_params_from_model(BatchGetContactOptionsQuery),
|
||||
description=(
|
||||
"Resolve Contact IDs persisted in workflow recipient configuration. "
|
||||
"Contacts that resolve as ABSENT or are otherwise unavailable in the current workspace are omitted."
|
||||
),
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[BatchGetContactOptionsResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
query_params_from_request(BatchGetContactOptionsQuery, list_fields=("contact_ids",))
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/node-data-migration")
|
||||
class NodeDataMigrationAPI(Resource):
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Migrate node data from HITLv1 to HITLv2. "
|
||||
'A missing legacy version defaults to "1"; any other explicit version is rejected. '
|
||||
"This endpoint only returns the migrated Human Input v2 node data to the client. "
|
||||
"It does not update the workflow DSL."
|
||||
),
|
||||
)
|
||||
@console_ns.expect(console_ns.models[NodeDataMigrationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[NodeDataMigrationResponse.__name__])
|
||||
@console_ns.response(400, "Migration failed", console_ns.models[NodeDataMigrationFailureResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str):
|
||||
NodeDataMigrationPayload.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/human-input/email-provider")
|
||||
class HumanInputEmailProviderAPI(Resource):
|
||||
@console_ns.doc(description="Retrieve the current email provider settings for human input")
|
||||
@console_ns.response(200, "Success", console_ns.models[GetEmailProviderResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@console_ns.doc(description="update the current email provider settings for human input")
|
||||
@console_ns.expect(console_ns.models[SetEmailProviderRequest.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SetEmailProviderResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@is_admin_or_owner_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str):
|
||||
SetEmailProviderRequest.model_validate(console_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
@@ -8,14 +8,18 @@ paused human input forms in workflow/chatflow runs.
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from flask import Response
|
||||
from flask import Response, abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import ConfigDict, Field
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.human_input_v2_contracts import FormDefinitionResponse as HumanInputV2FormDefinitionResponse
|
||||
from controllers.common.human_input_v2_contracts import FormSubmitResponse as HumanInputV2FormSubmitResponse
|
||||
from controllers.common.human_input_v2_contracts import HumanInputV2ServiceFormSubmitRequest, ServiceFormQuery
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
@@ -43,8 +47,19 @@ class HumanInputFormSubmitResponse(ResponseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
register_schema_models(service_api_ns, HumanInputFormSubmitPayload)
|
||||
register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse)
|
||||
register_schema_models(
|
||||
service_api_ns,
|
||||
HumanInputFormSubmitPayload,
|
||||
HumanInputV2ServiceFormSubmitRequest,
|
||||
ServiceFormQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
service_api_ns,
|
||||
HumanInputFormDefinitionResponse,
|
||||
HumanInputFormSubmitResponse,
|
||||
HumanInputV2FormDefinitionResponse,
|
||||
HumanInputV2FormSubmitResponse,
|
||||
)
|
||||
|
||||
|
||||
def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response:
|
||||
@@ -188,3 +203,33 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
raise NotFound("Form not found")
|
||||
|
||||
return {}, 200
|
||||
|
||||
|
||||
@service_api_ns.route("/form/human-input/<string:form_token>")
|
||||
class WorkflowHumanInputV2FormApi(Resource):
|
||||
"""Trusted Service API stub for Human Input v2 forms.
|
||||
|
||||
This route does not alias the v1 handler. The implementation must resolve a
|
||||
v2 form and reject v1 form tokens before reading or submitting it.
|
||||
"""
|
||||
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Form retrieved successfully",
|
||||
service_api_ns.models[HumanInputV2FormDefinitionResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
|
||||
def get(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
ServiceFormQuery.model_validate(request.args.to_dict(flat=True))
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[HumanInputV2ServiceFormSubmitRequest.__name__])
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Form submitted successfully",
|
||||
service_api_ns.models[HumanInputV2FormSubmitResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
|
||||
def post(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
HumanInputV2ServiceFormSubmitRequest.model_validate(service_api_ns.payload or {})
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
|
||||
|
||||
@@ -25,6 +25,7 @@ from . import (
|
||||
forgot_password,
|
||||
human_input_file_upload,
|
||||
human_input_form,
|
||||
human_input_form_access_request,
|
||||
login,
|
||||
message,
|
||||
passport,
|
||||
@@ -49,6 +50,7 @@ __all__ = [
|
||||
"forgot_password",
|
||||
"human_input_file_upload",
|
||||
"human_input_form",
|
||||
"human_input_form_access_request",
|
||||
"login",
|
||||
"message",
|
||||
"passport",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Public web Human Input v2 form stubs.
|
||||
|
||||
The hyphenated v2 routes are intentionally separate from the legacy underscored
|
||||
Human Input form routes. Each runtime path must reject tokens owned by the other
|
||||
version when the service implementation is added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
from flask import abort
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
HumanInputV2FormSubmitRequest,
|
||||
)
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.web import web_ns
|
||||
|
||||
register_schema_models(web_ns, HumanInputV2FormSubmitRequest)
|
||||
register_response_schema_models(
|
||||
web_ns,
|
||||
FormAccessRequestResponse,
|
||||
FormDefinitionResponse,
|
||||
FormSubmitResponse,
|
||||
FormUploadTokenResponse,
|
||||
)
|
||||
|
||||
|
||||
def _raise_stub_not_implemented() -> None:
|
||||
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 form stub endpoint is not implemented yet.")
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>")
|
||||
class HumanInputV2FormApi(Resource):
|
||||
"""Read or submit a Human Input v2 form without sharing v1 submission logic."""
|
||||
|
||||
@web_ns.response(200, "Success", web_ns.models[FormDefinitionResponse.__name__])
|
||||
def get(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
@web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__])
|
||||
@web_ns.response(200, "Success", web_ns.models[FormSubmitResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
HumanInputV2FormSubmitRequest.model_validate(web_ns.payload or {})
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>/upload-token")
|
||||
class HumanInputV2FormUploadTokenApi(Resource):
|
||||
"""Issue an upload token for an active Human Input v2 form."""
|
||||
|
||||
@web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
|
||||
|
||||
@web_ns.route("/form/human-input/<string:form_token>/access-request")
|
||||
class FormAccessRequestApi(Resource):
|
||||
@web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__])
|
||||
def post(self, form_token: str):
|
||||
_raise_stub_not_implemented()
|
||||
@@ -88,7 +88,7 @@ from graphon.graph_events import (
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
|
||||
from models.workflow import Workflow
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
|
||||
from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -715,12 +715,12 @@ class WorkflowBasedAppRunner:
|
||||
if not reason.form_id:
|
||||
continue
|
||||
try:
|
||||
dispatch_human_input_email_task.apply_async(
|
||||
dispatch_human_input_form_delivery_task.apply_async(
|
||||
kwargs={"form_id": reason.form_id, "node_title": reason.node_title},
|
||||
queue="mail",
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive logging
|
||||
logger.exception("Failed to enqueue human input email task for form %s", reason.form_id)
|
||||
logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id)
|
||||
|
||||
def _publish_event(self, event: AppQueueEvent):
|
||||
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Human Input v2 recipient planning, Form, and OTP aggregate boundaries.
|
||||
|
||||
The package owns separate Form and OTP aggregate boundaries plus grant,
|
||||
delivery, and upload facts. It exposes domain and persistence ports without
|
||||
importing transport, provider, database-session, or ORM concerns.
|
||||
"""
|
||||
|
||||
from .delivery import (
|
||||
ConsoleEndpointConfiguration,
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpoint,
|
||||
DeliveryEndpointConfiguration,
|
||||
EmailEndpointConfiguration,
|
||||
EmailProviderConfiguration,
|
||||
EndpointAccessCapability,
|
||||
IMEndpointConfiguration,
|
||||
UploadCapability,
|
||||
UploadCapabilityRef,
|
||||
UploadFileAssociation,
|
||||
WebEndpointConfiguration,
|
||||
)
|
||||
from .form import (
|
||||
FormCreation,
|
||||
FormInactiveReason,
|
||||
FormSnapshotIdentifierFactory,
|
||||
FormState,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
HumanInputForm,
|
||||
InactiveFormState,
|
||||
InvalidApproverGrantError,
|
||||
InvalidSelectedActionError,
|
||||
SubmissionTransitionDecision,
|
||||
WaitingFormState,
|
||||
)
|
||||
from .frozen_values import FrozenJSONArray, FrozenJSONObject, JSONPrimitive
|
||||
from .grants import ApproverGrant, ApproverGrantRef, DeliveryEndpointRef, FormRef, OTPChallengeRef
|
||||
from .otp import (
|
||||
Clock,
|
||||
ContactOTPSubject,
|
||||
CurrentEmailOTPIdentity,
|
||||
EmailAddressOTPSubject,
|
||||
EmailOTPProofAuthorizationDecision,
|
||||
EmailOTPSubject,
|
||||
OTPChallenge,
|
||||
OTPChallengePublicPrimitive,
|
||||
OTPChallengeRejectionReason,
|
||||
OTPChallengeRepository,
|
||||
OTPChallengeState,
|
||||
OTPCodeHash,
|
||||
OTPCodeHasher,
|
||||
OTPReplacementDecision,
|
||||
OTPVerificationDecision,
|
||||
VerifiedEmailOTPProof,
|
||||
VerifiedEmailOTPProofPrimitive,
|
||||
authorize_email_otp_proof,
|
||||
)
|
||||
from .ports import FormDefinitionProjection, FormDeliveryProjection, FormRepository
|
||||
from .recipient_resolution import (
|
||||
ApprovalSubject,
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointPlan,
|
||||
ContactApprovalSubject,
|
||||
ContactInitiatorSnapshot,
|
||||
DebugRecipientReplacement,
|
||||
DeliveryCapabilitySnapshot,
|
||||
DeliveryEndpointPlan,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointPlan,
|
||||
EndUserApprovalSubject,
|
||||
EndUserInitiatorSnapshot,
|
||||
IMEndpointPlan,
|
||||
MatchedRecipientSource,
|
||||
RecipientRejectionReason,
|
||||
RecipientResolutionFailureReason,
|
||||
RecipientResolver,
|
||||
RecipientSourceKind,
|
||||
RejectedRecipient,
|
||||
ResolvedApprovalPlan,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
from .recipient_specifications import (
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
RecipientSpecification,
|
||||
RecipientSpecificationKind,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
WorkflowRecipientSpecificationAdapter,
|
||||
)
|
||||
from .submission_authorization import (
|
||||
AccountSubmissionActor,
|
||||
AuthorizationContext,
|
||||
AuthorizedSubmission,
|
||||
CurrentContactAuthorizationFacts,
|
||||
CurrentEndUserAuthorizationFacts,
|
||||
CurrentIMAuthorizationFacts,
|
||||
EmailAddressSubmissionActor,
|
||||
EndUserSubmissionActor,
|
||||
SubmissionActor,
|
||||
SubmissionAuthorizationDecision,
|
||||
SubmissionAuthorizationRejection,
|
||||
SubmissionAuthorizer,
|
||||
VerifiedAccountSessionProof,
|
||||
VerifiedIMIdentityProof,
|
||||
VerifiedSubmissionProof,
|
||||
VerifiedTrustedEndUserProof,
|
||||
)
|
||||
from .submission_ports import (
|
||||
AuthorizedSubmissionCommit,
|
||||
RetryableSubmissionPersistenceError,
|
||||
SubmissionAttemptScope,
|
||||
SubmissionCommitResult,
|
||||
SubmissionCommitStatus,
|
||||
SubmissionRepository,
|
||||
SubmissionTransaction,
|
||||
)
|
||||
from .submission_records import FormAuthorizationAuditEvent, FormAuthorizationAuditEventType, FormSubmission
|
||||
|
||||
__all__ = [
|
||||
"AccountSubmissionActor",
|
||||
"ApprovalSubject",
|
||||
"ApproverGrant",
|
||||
"ApproverGrantRef",
|
||||
"AuthorizationContext",
|
||||
"AuthorizedSubmission",
|
||||
"AuthorizedSubmissionCommit",
|
||||
"CanonicalSubjectKey",
|
||||
"Clock",
|
||||
"ConsoleEndpointConfiguration",
|
||||
"ConsoleEndpointPlan",
|
||||
"ContactApprovalSubject",
|
||||
"ContactInitiatorSnapshot",
|
||||
"ContactOTPSubject",
|
||||
"ContactRecipientSpecification",
|
||||
"CurrentContactAuthorizationFacts",
|
||||
"CurrentEmailOTPIdentity",
|
||||
"CurrentEndUserAuthorizationFacts",
|
||||
"CurrentIMAuthorizationFacts",
|
||||
"CurrentInitiatorRecipientSpecification",
|
||||
"DebugRecipientReplacement",
|
||||
"DeliveryAttempt",
|
||||
"DeliveryCapabilitySnapshot",
|
||||
"DeliveryEndpoint",
|
||||
"DeliveryEndpointConfiguration",
|
||||
"DeliveryEndpointPlan",
|
||||
"DeliveryEndpointRef",
|
||||
"DynamicEmailRecipientSpecification",
|
||||
"DynamicRecipientValue",
|
||||
"EmailAddressApprovalSubject",
|
||||
"EmailAddressOTPSubject",
|
||||
"EmailAddressSubmissionActor",
|
||||
"EmailEndpointConfiguration",
|
||||
"EmailEndpointPlan",
|
||||
"EmailOTPProofAuthorizationDecision",
|
||||
"EmailOTPSubject",
|
||||
"EmailProviderConfiguration",
|
||||
"EndUserApprovalSubject",
|
||||
"EndUserInitiatorSnapshot",
|
||||
"EndUserSubmissionActor",
|
||||
"EndpointAccessCapability",
|
||||
"FormAuthorizationAuditEvent",
|
||||
"FormAuthorizationAuditEventType",
|
||||
"FormCreation",
|
||||
"FormDefinitionProjection",
|
||||
"FormDeliveryProjection",
|
||||
"FormInactiveReason",
|
||||
"FormRef",
|
||||
"FormRepository",
|
||||
"FormSnapshotIdentifierFactory",
|
||||
"FormState",
|
||||
"FormSubmission",
|
||||
"FrozenFormAction",
|
||||
"FrozenFormDefinition",
|
||||
"FrozenJSONArray",
|
||||
"FrozenJSONObject",
|
||||
"HumanInputForm",
|
||||
"IMEndpointConfiguration",
|
||||
"IMEndpointPlan",
|
||||
"InactiveFormState",
|
||||
"InvalidApproverGrantError",
|
||||
"InvalidSelectedActionError",
|
||||
"JSONPrimitive",
|
||||
"MatchedRecipientSource",
|
||||
"OTPChallenge",
|
||||
"OTPChallengePublicPrimitive",
|
||||
"OTPChallengeRef",
|
||||
"OTPChallengeRejectionReason",
|
||||
"OTPChallengeRepository",
|
||||
"OTPChallengeState",
|
||||
"OTPCodeHash",
|
||||
"OTPCodeHasher",
|
||||
"OTPReplacementDecision",
|
||||
"OTPVerificationDecision",
|
||||
"OneTimeEmailRecipientSpecification",
|
||||
"RecipientRejectionReason",
|
||||
"RecipientResolutionFailureReason",
|
||||
"RecipientResolver",
|
||||
"RecipientSourceKind",
|
||||
"RecipientSpecification",
|
||||
"RecipientSpecificationKind",
|
||||
"RejectedRecipient",
|
||||
"ResolvedApprovalPlan",
|
||||
"ResolvedApprover",
|
||||
"RetryableSubmissionPersistenceError",
|
||||
"SubjectSnapshot",
|
||||
"SubmissionActor",
|
||||
"SubmissionAttemptScope",
|
||||
"SubmissionAuthorizationDecision",
|
||||
"SubmissionAuthorizationRejection",
|
||||
"SubmissionAuthorizer",
|
||||
"SubmissionCommitResult",
|
||||
"SubmissionCommitStatus",
|
||||
"SubmissionRepository",
|
||||
"SubmissionTransaction",
|
||||
"SubmissionTransitionDecision",
|
||||
"UnsupportedDynamicRecipientValue",
|
||||
"UploadCapability",
|
||||
"UploadCapabilityRef",
|
||||
"UploadFileAssociation",
|
||||
"VerifiedAccountSessionProof",
|
||||
"VerifiedEmailOTPProof",
|
||||
"VerifiedEmailOTPProofPrimitive",
|
||||
"VerifiedIMIdentityProof",
|
||||
"VerifiedSubmissionProof",
|
||||
"VerifiedTrustedEndUserProof",
|
||||
"WaitingFormState",
|
||||
"WebEndpointConfiguration",
|
||||
"WebEndpointPlan",
|
||||
"WorkflowRecipientSpecificationAdapter",
|
||||
"authorize_email_otp_proof",
|
||||
]
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Frozen delivery, provider, endpoint-token, and upload facts.
|
||||
|
||||
Endpoints describe where a form can be delivered or interacted with. Their
|
||||
tokens are scoped capabilities only; this module deliberately exposes no actor
|
||||
or verified-proof conversion. Delivery attempts are append-only diagnostics and
|
||||
cannot mutate :class:`HumanInputForm` lifecycle state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.entities import (
|
||||
EmailProviderType,
|
||||
HumanInputDeliveryAttemptStatus,
|
||||
HumanInputDeliveryChannel,
|
||||
IMProvider,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
EmailProviderId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import ApproverGrantRef, DeliveryEndpointRef
|
||||
from .recipient_resolution import (
|
||||
ConsoleEndpointPlan,
|
||||
DeliveryEndpointPlan,
|
||||
EmailEndpointPlan,
|
||||
IMEndpointPlan,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
|
||||
|
||||
def _validate_sha256(value: str, *, label: str) -> None:
|
||||
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
||||
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndpointAccessCapability:
|
||||
"""Hashed endpoint interaction capability that carries no identity proof."""
|
||||
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
token_hash: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.token_hash, label="endpoint access token hash")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailEndpointConfiguration:
|
||||
"""Frozen Email delivery address."""
|
||||
|
||||
email_address: NormalizedEmail
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.EMAIL
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMEndpointConfiguration:
|
||||
"""Credential-free IM interaction snapshot owned by one integration."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id or not self.provider_user_id:
|
||||
raise ValueError("IM endpoint provider identities must not be blank")
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.IM
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebEndpointConfiguration:
|
||||
"""Public or trusted-app web interaction surface."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.WEB
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleEndpointConfiguration:
|
||||
"""Authenticated console interaction surface."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.CONSOLE
|
||||
|
||||
|
||||
type DeliveryEndpointConfiguration = (
|
||||
EmailEndpointConfiguration | IMEndpointConfiguration | WebEndpointConfiguration | ConsoleEndpointConfiguration
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryEndpoint:
|
||||
"""Historical endpoint snapshot distinct from its approver grant."""
|
||||
|
||||
ref: DeliveryEndpointRef
|
||||
configuration: DeliveryEndpointConfiguration
|
||||
address_hash: str
|
||||
access_capability: EndpointAccessCapability | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.address_hash, label="endpoint address hash")
|
||||
if self.access_capability is not None and self.access_capability.endpoint_ref != self.ref:
|
||||
raise ValueError("endpoint access capability owner does not match the endpoint")
|
||||
|
||||
@property
|
||||
def id(self) -> DeliveryEndpointId:
|
||||
return self.ref.endpoint_id
|
||||
|
||||
@property
|
||||
def grant_ref(self) -> ApproverGrantRef:
|
||||
return self.ref.grant_ref
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return self.configuration.channel
|
||||
|
||||
@classmethod
|
||||
def from_plan(
|
||||
cls,
|
||||
*,
|
||||
endpoint_id: DeliveryEndpointId,
|
||||
grant_ref: ApproverGrantRef,
|
||||
endpoint_plan: DeliveryEndpointPlan,
|
||||
access_capability: EndpointAccessCapability | None,
|
||||
now: UtcTimestamp,
|
||||
) -> DeliveryEndpoint:
|
||||
endpoint_ref = grant_ref.endpoint(endpoint_id)
|
||||
configuration: DeliveryEndpointConfiguration
|
||||
canonical_address: str
|
||||
match endpoint_plan:
|
||||
case EmailEndpointPlan(email_address=email_address):
|
||||
configuration = EmailEndpointConfiguration(email_address)
|
||||
canonical_address = f"email:{email_address}"
|
||||
case IMEndpointPlan(
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=provider_user_id,
|
||||
):
|
||||
configuration = IMEndpointConfiguration(
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=provider_user_id,
|
||||
)
|
||||
canonical_address = f"im:{integration_id}:{provider.value}:{provider_user_id}"
|
||||
case WebEndpointPlan():
|
||||
configuration = WebEndpointConfiguration()
|
||||
canonical_address = "web"
|
||||
case ConsoleEndpointPlan():
|
||||
configuration = ConsoleEndpointConfiguration()
|
||||
canonical_address = "console"
|
||||
case _:
|
||||
assert_never(endpoint_plan)
|
||||
return cls(
|
||||
ref=endpoint_ref,
|
||||
configuration=configuration,
|
||||
address_hash=sha256(canonical_address.encode()).hexdigest(),
|
||||
access_capability=access_capability,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryAttempt:
|
||||
"""Append-only provider delivery outcome scoped to one endpoint."""
|
||||
|
||||
id: DeliveryAttemptId
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
attempt_number: int
|
||||
status: HumanInputDeliveryAttemptStatus
|
||||
scheduled_at: UtcTimestamp
|
||||
started_at: UtcTimestamp | None
|
||||
finished_at: UtcTimestamp | None
|
||||
provider_message_id: str | None
|
||||
failure_code: str | None
|
||||
failure_reason: str | None
|
||||
provider_response: FrozenJSONObject | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.attempt_number < 1:
|
||||
raise ValueError("delivery attempt number must be positive")
|
||||
if self.status is HumanInputDeliveryAttemptStatus.FAILED:
|
||||
if self.finished_at is None:
|
||||
raise ValueError("failed delivery attempt requires finished_at")
|
||||
has_failure_code = self.failure_code is not None and bool(self.failure_code.strip())
|
||||
has_failure_reason = self.failure_reason is not None and bool(self.failure_reason.strip())
|
||||
if not has_failure_code and not has_failure_reason and self.provider_response is None:
|
||||
raise ValueError("failed delivery attempt requires a failure diagnostic")
|
||||
if self.status is not HumanInputDeliveryAttemptStatus.FAILED and (
|
||||
self.failure_code is not None or self.failure_reason is not None
|
||||
):
|
||||
raise ValueError("only failed delivery attempts may contain failure diagnostics")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailProviderConfiguration:
|
||||
"""Workspace provider configuration kept outside the form domain lifecycle."""
|
||||
|
||||
id: EmailProviderId
|
||||
workspace_id: WorkspaceId
|
||||
provider: EmailProviderType
|
||||
sender_email: NormalizedEmail
|
||||
sender_name: str
|
||||
encrypted_credentials: FrozenJSONObject
|
||||
configured_by_account_id: AccountId | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadCapabilityRef:
|
||||
"""Upload capability reference carrying the complete endpoint owner chain."""
|
||||
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
capability_id: UploadCapabilityId
|
||||
app_id: AppId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadCapability:
|
||||
"""Hashed upload capability scoped to exactly one form endpoint."""
|
||||
|
||||
id: UploadCapabilityId
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
app_id: AppId
|
||||
token_hash: str
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.token_hash, label="upload token hash")
|
||||
|
||||
@property
|
||||
def ref(self) -> UploadCapabilityRef:
|
||||
return UploadCapabilityRef(self.endpoint_ref, self.id, self.app_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadFileAssociation:
|
||||
"""Durable file fact whose scope is inherited from its upload capability."""
|
||||
|
||||
id: UploadFileAssociationId
|
||||
capability_ref: UploadCapabilityRef
|
||||
upload_file_id: str
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.upload_file_id.strip():
|
||||
raise ValueError("upload file id must not be blank")
|
||||
@@ -0,0 +1,261 @@
|
||||
"""Rich Human Input v2 form aggregate and plan-to-snapshot creation.
|
||||
|
||||
``HumanInputForm`` directly owns every local lifecycle decision: persisted
|
||||
status, node/global expiry, grant membership, and selected actions. It returns a
|
||||
transition decision only; committing the first successful submission belongs to
|
||||
the later submission transaction boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import HumanInputV2FormKind, HumanInputV2FormStatus
|
||||
from core.human_input_v2.shared import (
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
DeliveryEndpointId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .delivery import DeliveryEndpoint
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import ApproverGrant, FormRef
|
||||
from .recipient_resolution import ResolvedApprovalPlan
|
||||
|
||||
|
||||
class InvalidApproverGrantError(ValueError):
|
||||
"""The selected grant does not belong to this form snapshot."""
|
||||
|
||||
|
||||
class InvalidSelectedActionError(ValueError):
|
||||
"""The selected action is absent from the frozen definition."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenFormAction:
|
||||
"""Immutable action values required for display and transition validation."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
button_style: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.id or not self.title or not self.button_style:
|
||||
raise ValueError("frozen form action values must not be blank")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenFormDefinition:
|
||||
"""Immutable render and validation definition captured at form creation."""
|
||||
|
||||
form_content: str
|
||||
inputs: tuple[FrozenJSONObject, ...]
|
||||
actions: tuple[FrozenFormAction, ...]
|
||||
default_values: FrozenJSONObject
|
||||
node_title: str | None
|
||||
display_in_ui: bool | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.inputs, tuple) or not isinstance(self.actions, tuple):
|
||||
raise TypeError("frozen form definition collections must be immutable tuples")
|
||||
action_ids = [action.id for action in self.actions]
|
||||
if len(action_ids) != len(set(action_ids)):
|
||||
raise ValueError("frozen form action identifiers must be unique")
|
||||
|
||||
def accepts_action(self, selected_action_id: str) -> bool:
|
||||
return any(action.id == selected_action_id for action in self.actions)
|
||||
|
||||
|
||||
class FormInactiveReason(StrEnum):
|
||||
"""Transport-neutral reason why a form cannot accept a transition."""
|
||||
|
||||
SUBMITTED = "submitted"
|
||||
TIMED_OUT = "timed_out"
|
||||
STATUS_EXPIRED = "status_expired"
|
||||
GLOBALLY_EXPIRED = "globally_expired"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WaitingFormState:
|
||||
"""Stable active-state result for a waiting form."""
|
||||
|
||||
is_waiting: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class InactiveFormState:
|
||||
"""Stable inactive-state result independent from HTTP status codes."""
|
||||
|
||||
reason: FormInactiveReason
|
||||
is_waiting: bool = False
|
||||
|
||||
|
||||
type FormState = WaitingFormState | InactiveFormState
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionTransitionDecision:
|
||||
"""Validated intent that does not claim persistence has committed."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant_id: ApproverGrantId
|
||||
selected_action_id: str
|
||||
decided_at: UtcTimestamp
|
||||
|
||||
|
||||
class FormSnapshotIdentifierFactory(Protocol):
|
||||
"""Provide child identifiers without coupling the domain to persistence."""
|
||||
|
||||
def new_grant_id(self) -> ApproverGrantId: ...
|
||||
|
||||
def new_endpoint_id(self) -> DeliveryEndpointId: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormCreation:
|
||||
"""Complete form/grant/endpoint snapshot persisted by one transaction."""
|
||||
|
||||
form: HumanInputForm
|
||||
endpoints: tuple[DeliveryEndpoint, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.endpoints, tuple):
|
||||
raise TypeError("form creation endpoints must be an immutable tuple")
|
||||
grant_refs = {grant.ref for grant in self.form.grants}
|
||||
if any(endpoint.grant_ref not in grant_refs for endpoint in self.endpoints):
|
||||
raise ValueError("form creation contains an endpoint outside its grants")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HumanInputForm:
|
||||
"""Form root owning local lifecycle and submission transition invariants."""
|
||||
|
||||
ref: FormRef
|
||||
app_id: AppId
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
node_timeout_at: UtcTimestamp
|
||||
global_expires_at: UtcTimestamp
|
||||
kind: HumanInputV2FormKind
|
||||
status: HumanInputV2FormStatus
|
||||
workflow_pause_id: str | None
|
||||
node_execution_id: str | None
|
||||
grants: tuple[ApproverGrant, ...]
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.grants, tuple):
|
||||
raise TypeError("form grants must be an immutable tuple")
|
||||
if self.kind is HumanInputV2FormKind.RUNTIME and (
|
||||
self.workflow_pause_id is None or self.node_execution_id is None
|
||||
):
|
||||
raise ValueError("runtime form requires workflow pause and node execution owners")
|
||||
if any(grant.ref.form_ref != self.ref for grant in self.grants):
|
||||
raise ValueError("form contains a grant from another owner")
|
||||
grant_ids = [grant.id for grant in self.grants]
|
||||
subject_keys = [grant.subject_key for grant in self.grants]
|
||||
if len(grant_ids) != len(set(grant_ids)) or len(subject_keys) != len(set(subject_keys)):
|
||||
raise ValueError("form grants must have unique identifiers and canonical subjects")
|
||||
|
||||
def state_at(self, now: UtcTimestamp) -> FormState:
|
||||
"""Return a stable status/expiry decision without changing persisted state."""
|
||||
|
||||
match self.status:
|
||||
case HumanInputV2FormStatus.SUBMITTED:
|
||||
return InactiveFormState(FormInactiveReason.SUBMITTED)
|
||||
case HumanInputV2FormStatus.TIMEOUT:
|
||||
return InactiveFormState(FormInactiveReason.TIMED_OUT)
|
||||
case HumanInputV2FormStatus.EXPIRED:
|
||||
return InactiveFormState(FormInactiveReason.STATUS_EXPIRED)
|
||||
case HumanInputV2FormStatus.WAITING:
|
||||
if now.value >= self.global_expires_at.value:
|
||||
return InactiveFormState(FormInactiveReason.GLOBALLY_EXPIRED)
|
||||
if now.value >= self.node_timeout_at.value:
|
||||
return InactiveFormState(FormInactiveReason.TIMED_OUT)
|
||||
return WaitingFormState()
|
||||
raise AssertionError(f"unsupported Human Input form status: {self.status}")
|
||||
|
||||
def decide_submission(
|
||||
self,
|
||||
*,
|
||||
grant_id: ApproverGrantId,
|
||||
selected_action_id: str,
|
||||
now: UtcTimestamp,
|
||||
) -> SubmissionTransitionDecision | InactiveFormState:
|
||||
"""Validate one local transition without mutating or persisting the form."""
|
||||
|
||||
state = self.state_at(now)
|
||||
if isinstance(state, InactiveFormState):
|
||||
return state
|
||||
if not any(grant.id == grant_id for grant in self.grants):
|
||||
raise InvalidApproverGrantError(str(grant_id))
|
||||
if not self.definition.accepts_action(selected_action_id):
|
||||
raise InvalidSelectedActionError(selected_action_id)
|
||||
return SubmissionTransitionDecision(
|
||||
form_ref=self.ref,
|
||||
grant_id=grant_id,
|
||||
selected_action_id=selected_action_id,
|
||||
decided_at=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create_from_plan(
|
||||
cls,
|
||||
*,
|
||||
ref: FormRef,
|
||||
app_id: AppId,
|
||||
definition: FrozenFormDefinition,
|
||||
rendered_content: str,
|
||||
node_timeout_at: UtcTimestamp,
|
||||
global_expires_at: UtcTimestamp,
|
||||
kind: HumanInputV2FormKind,
|
||||
workflow_pause_id: str | None,
|
||||
node_execution_id: str | None,
|
||||
plan: ResolvedApprovalPlan,
|
||||
identifier_factory: FormSnapshotIdentifierFactory,
|
||||
now: UtcTimestamp,
|
||||
) -> FormCreation:
|
||||
"""Map one deterministic resolved plan into a complete frozen snapshot."""
|
||||
|
||||
if not plan.approvers:
|
||||
raise ValueError("form creation requires resolved approvers")
|
||||
grants: list[ApproverGrant] = []
|
||||
endpoints: list[DeliveryEndpoint] = []
|
||||
for approver in plan.approvers:
|
||||
grant = ApproverGrant.from_resolved_approver(
|
||||
grant_id=identifier_factory.new_grant_id(),
|
||||
form_ref=ref,
|
||||
approver=approver,
|
||||
now=now,
|
||||
)
|
||||
grants.append(grant)
|
||||
for endpoint_plan in approver.endpoints:
|
||||
endpoints.append(
|
||||
DeliveryEndpoint.from_plan(
|
||||
endpoint_id=identifier_factory.new_endpoint_id(),
|
||||
grant_ref=grant.ref,
|
||||
endpoint_plan=endpoint_plan,
|
||||
access_capability=None,
|
||||
now=now,
|
||||
)
|
||||
)
|
||||
form = cls(
|
||||
ref=ref,
|
||||
app_id=app_id,
|
||||
definition=definition,
|
||||
rendered_content=rendered_content,
|
||||
node_timeout_at=node_timeout_at,
|
||||
global_expires_at=global_expires_at,
|
||||
kind=kind,
|
||||
status=HumanInputV2FormStatus.WAITING,
|
||||
workflow_pause_id=workflow_pause_id,
|
||||
node_execution_id=node_execution_id,
|
||||
grants=tuple(grants),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return FormCreation(form=form, endpoints=tuple(endpoints))
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Recursively immutable JSON values used by frozen form snapshots.
|
||||
|
||||
Domain code never receives mutable dictionaries from persistence or provider
|
||||
boundaries. Explicit conversion methods create fresh primitive containers only
|
||||
when a caller crosses such a boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
type JSONScalar = str | int | float | bool | None
|
||||
type JSONPrimitive = JSONScalar | list[JSONPrimitive] | dict[str, JSONPrimitive]
|
||||
type FrozenJSONValue = JSONScalar | FrozenJSONArray | FrozenJSONObject
|
||||
|
||||
|
||||
def _validate_frozen_json(value: object) -> None:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return
|
||||
if isinstance(value, float):
|
||||
if not isfinite(value):
|
||||
raise ValueError("JSON numbers must be finite")
|
||||
return
|
||||
if isinstance(value, (FrozenJSONArray, FrozenJSONObject)):
|
||||
return
|
||||
raise TypeError(f"frozen JSON values cannot contain {type(value).__name__}")
|
||||
|
||||
|
||||
def _freeze_json(value: object) -> FrozenJSONValue:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not isfinite(value):
|
||||
raise ValueError("JSON numbers must be finite")
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return FrozenJSONObject.from_mapping(value)
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return FrozenJSONArray(tuple(_freeze_json(item) for item in value))
|
||||
raise TypeError(f"unsupported JSON value type: {type(value).__name__}")
|
||||
|
||||
|
||||
def _thaw_json(value: FrozenJSONValue) -> JSONPrimitive:
|
||||
if isinstance(value, FrozenJSONObject):
|
||||
return value.to_mapping()
|
||||
if isinstance(value, FrozenJSONArray):
|
||||
return value.to_list()
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenJSONArray:
|
||||
"""Immutable JSON array with an explicit primitive conversion boundary."""
|
||||
|
||||
values: tuple[FrozenJSONValue, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.values, tuple):
|
||||
raise TypeError("frozen JSON array values must be an immutable tuple")
|
||||
for value in self.values:
|
||||
_validate_frozen_json(value)
|
||||
|
||||
def to_list(self) -> list[JSONPrimitive]:
|
||||
return [_thaw_json(value) for value in self.values]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrozenJSONObject:
|
||||
"""Immutable ordered JSON object independent from Pydantic and ORM types."""
|
||||
|
||||
entries: tuple[tuple[str, FrozenJSONValue], ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.entries, tuple):
|
||||
raise TypeError("frozen JSON object entries must be an immutable tuple")
|
||||
seen_keys: set[str] = set()
|
||||
for entry in self.entries:
|
||||
if not isinstance(entry, tuple) or len(entry) != 2:
|
||||
raise TypeError("frozen JSON object entries must be key-value tuples")
|
||||
key, value = entry
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("JSON objects require string keys")
|
||||
if key in seen_keys:
|
||||
raise ValueError(f"duplicate JSON object key: {key}")
|
||||
seen_keys.add(key)
|
||||
_validate_frozen_json(value)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, object]) -> FrozenJSONObject:
|
||||
entries: list[tuple[str, FrozenJSONValue]] = []
|
||||
for key in sorted(values, key=lambda candidate: str(candidate)):
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("JSON objects require string keys")
|
||||
entries.append((key, _freeze_json(values[key])))
|
||||
return cls(tuple(entries))
|
||||
|
||||
def to_mapping(self) -> dict[str, JSONPrimitive]:
|
||||
return {key: _thaw_json(value) for key, value in self.entries}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Historical approver grants and form-scoped logical references.
|
||||
|
||||
Grants capture candidate authority at form creation. They are intentionally not
|
||||
current authorization proofs: later submission code must revalidate the current
|
||||
identity behind the frozen subject.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
DeliveryEndpointId,
|
||||
FormId,
|
||||
OTPChallengeId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .recipient_resolution import (
|
||||
ApprovalSubject,
|
||||
CanonicalSubjectKey,
|
||||
ContactApprovalSubject,
|
||||
EmailAddressApprovalSubject,
|
||||
EndUserApprovalSubject,
|
||||
MatchedRecipientSource,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
)
|
||||
|
||||
|
||||
def _subject_key(subject: ApprovalSubject) -> CanonicalSubjectKey:
|
||||
match subject:
|
||||
case ContactApprovalSubject(contact_id=contact_id):
|
||||
return CanonicalSubjectKey.for_contact(contact_id)
|
||||
case EndUserApprovalSubject(end_user_id=end_user_id):
|
||||
return CanonicalSubjectKey.for_end_user(end_user_id)
|
||||
case EmailAddressApprovalSubject(normalized_email=normalized_email):
|
||||
return CanonicalSubjectKey.for_email(normalized_email)
|
||||
case _:
|
||||
assert_never(subject)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormRef:
|
||||
"""Workspace-owned root reference; authorization still requires scoped queries."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
form_id: FormId
|
||||
|
||||
def grant(self, grant_id: ApproverGrantId) -> ApproverGrantRef:
|
||||
return ApproverGrantRef(self, grant_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApproverGrantRef:
|
||||
"""Grant reference carrying its complete form owner chain."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant_id: ApproverGrantId
|
||||
|
||||
def endpoint(self, endpoint_id: DeliveryEndpointId) -> DeliveryEndpointRef:
|
||||
return DeliveryEndpointRef(self, endpoint_id)
|
||||
|
||||
def challenge(self, challenge_id: OTPChallengeId) -> OTPChallengeRef:
|
||||
return OTPChallengeRef(self, challenge_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallengeRef:
|
||||
"""OTP proof-session reference carrying its complete grant owner chain."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
challenge_id: OTPChallengeId
|
||||
|
||||
@property
|
||||
def form_ref(self) -> FormRef:
|
||||
return self.grant_ref.form_ref
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryEndpointRef:
|
||||
"""Endpoint reference carrying grant, form, and workspace ownership."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
endpoint_id: DeliveryEndpointId
|
||||
|
||||
@property
|
||||
def form_ref(self) -> FormRef:
|
||||
return self.grant_ref.form_ref
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApproverGrant:
|
||||
"""Frozen candidate approver with historical matched-source facts."""
|
||||
|
||||
ref: ApproverGrantRef
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
matched_sources: tuple[MatchedRecipientSource, ...]
|
||||
subject_snapshot: SubjectSnapshot
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.matched_sources, tuple):
|
||||
raise TypeError("matched sources must be an immutable tuple")
|
||||
if self.subject_key != _subject_key(self.subject):
|
||||
raise ValueError("approver grant subject key does not match its subject")
|
||||
|
||||
@property
|
||||
def id(self) -> ApproverGrantId:
|
||||
return self.ref.grant_id
|
||||
|
||||
@classmethod
|
||||
def from_resolved_approver(
|
||||
cls,
|
||||
*,
|
||||
grant_id: ApproverGrantId,
|
||||
form_ref: FormRef,
|
||||
approver: ResolvedApprover,
|
||||
now: UtcTimestamp,
|
||||
) -> ApproverGrant:
|
||||
return cls(
|
||||
ref=form_ref.grant(grant_id),
|
||||
subject=approver.subject,
|
||||
subject_key=approver.subject_key,
|
||||
matched_sources=approver.matched_sources,
|
||||
subject_snapshot=approver.subject_snapshot,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -0,0 +1,460 @@
|
||||
"""Independent Email OTP proof-session lifecycle and limited proof boundary.
|
||||
|
||||
The aggregate owns only challenge expiry, cooldown, counters, verification, and
|
||||
invalidation. It never reads or mutates :class:`HumanInputForm`; submission code
|
||||
must separately compare a verified proof with coherent current identity facts.
|
||||
Plaintext codes are transient method inputs and are never retained or serialized.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Protocol, TypedDict
|
||||
|
||||
from core.human_input_v2.entities import HumanInputAuthorizationProofType, HumanInputOTPChallengeStatus
|
||||
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
|
||||
|
||||
from .grants import ApproverGrantRef, OTPChallengeRef
|
||||
|
||||
OTP_EXPIRY = timedelta(minutes=10)
|
||||
OTP_RESEND_COOLDOWN = timedelta(seconds=60)
|
||||
OTP_MAX_SEND_COUNT = 5
|
||||
OTP_MAX_ATTEMPT_COUNT = 5
|
||||
|
||||
|
||||
def _validate_sha256(value: str, *, label: str) -> None:
|
||||
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
||||
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
"""Narrow clock port used to make all lifecycle boundaries deterministic."""
|
||||
|
||||
def now(self) -> UtcTimestamp: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPCodeHash:
|
||||
"""Opaque encoded code digest plus the verifier algorithm discriminator."""
|
||||
|
||||
encoded_value: str
|
||||
algorithm: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.encoded_value or not self.algorithm.strip():
|
||||
raise ValueError("OTP code hash values must not be blank")
|
||||
|
||||
|
||||
class OTPCodeHasher(Protocol):
|
||||
"""Hash and verify transient plaintext without exposing implementation policy."""
|
||||
|
||||
def hash_code(self, plaintext_code: str) -> OTPCodeHash: ...
|
||||
|
||||
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactOTPSubject:
|
||||
"""Contact incarnation captured when an OTP challenge is issued."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressOTPSubject:
|
||||
"""Standalone normalized Email subject captured from a one-time grant."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
|
||||
type EmailOTPSubject = ContactOTPSubject | EmailAddressOTPSubject
|
||||
|
||||
|
||||
class OTPChallengeRejectionReason(StrEnum):
|
||||
"""Transport-neutral reason for a rejected OTP or proof operation."""
|
||||
|
||||
EXPIRED = "expired"
|
||||
RESEND_COOLDOWN = "resend_cooldown"
|
||||
SEND_LIMIT_REACHED = "send_limit_reached"
|
||||
ATTEMPT_LIMIT_REACHED = "attempt_limit_reached"
|
||||
ALREADY_VERIFIED = "already_verified"
|
||||
INVALIDATED = "invalidated"
|
||||
INVALID_CODE = "invalid_code"
|
||||
RAW_CODE_NOT_VERIFIED = "raw_code_not_verified"
|
||||
GRANT_MISMATCH = "grant_mismatch"
|
||||
STALE_IDENTITY = "stale_identity"
|
||||
|
||||
|
||||
class OTPChallengePublicPrimitive(TypedDict):
|
||||
"""Secret-free diagnostic form of challenge state."""
|
||||
|
||||
otp_challenge_id: str
|
||||
form_id: str
|
||||
approver_grant_id: str
|
||||
status: str
|
||||
email: str
|
||||
send_count: int
|
||||
attempt_count: int
|
||||
expires_at: str
|
||||
resend_after: str
|
||||
verified_at: str | None
|
||||
invalidated_at: str | None
|
||||
|
||||
|
||||
class VerifiedEmailOTPProofPrimitive(TypedDict):
|
||||
"""Primitive proof shape safe for authorization and audit boundaries."""
|
||||
|
||||
type: str
|
||||
otp_challenge_id: str
|
||||
form_id: str
|
||||
approver_grant_id: str
|
||||
subject_type: str
|
||||
contact_id: str | None
|
||||
verified_email: str
|
||||
verified_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedEmailOTPProof:
|
||||
"""Immutable Email verification fact that carries no submission authority."""
|
||||
|
||||
challenge_ref: OTPChallengeRef
|
||||
subject: EmailOTPSubject
|
||||
normalized_email: NormalizedEmail
|
||||
verified_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
|
||||
raise ValueError("OTP proof subject email must match the verified email")
|
||||
|
||||
def to_primitive(self) -> VerifiedEmailOTPProofPrimitive:
|
||||
contact_id: str | None = None
|
||||
subject_type = "email_address"
|
||||
if isinstance(self.subject, ContactOTPSubject):
|
||||
subject_type = "contact"
|
||||
contact_id = str(self.subject.contact_id)
|
||||
return {
|
||||
"type": HumanInputAuthorizationProofType.EMAIL_OTP.value,
|
||||
"otp_challenge_id": str(self.challenge_ref.challenge_id),
|
||||
"form_id": str(self.challenge_ref.form_ref.form_id),
|
||||
"approver_grant_id": str(self.challenge_ref.grant_ref.grant_id),
|
||||
"subject_type": subject_type,
|
||||
"contact_id": contact_id,
|
||||
"verified_email": str(self.normalized_email),
|
||||
"verified_at": self.verified_at.to_primitive(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallengeState:
|
||||
"""Stable current usability result independent from transport status codes."""
|
||||
|
||||
status: HumanInputOTPChallengeStatus
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
@property
|
||||
def is_usable(self) -> bool:
|
||||
return self.rejection is None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPReplacementDecision:
|
||||
"""Immutable replacement result; persistence commits both states atomically."""
|
||||
|
||||
previous: OTPChallenge
|
||||
replacement: OTPChallenge | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPVerificationDecision:
|
||||
"""Verification result containing either one limited proof or one rejection."""
|
||||
|
||||
challenge: OTPChallenge
|
||||
proof: VerifiedEmailOTPProof | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallenge:
|
||||
"""Grant-scoped proof session whose counters never touch Form lifecycle state.
|
||||
|
||||
Persisted sessions reconstruct only when cooldown and expiry are the exact
|
||||
durations derived from ``created_at``; stored timestamps are not policy input.
|
||||
"""
|
||||
|
||||
ref: OTPChallengeRef
|
||||
subject: EmailOTPSubject
|
||||
normalized_email: NormalizedEmail
|
||||
challenge_token_hash: str = field(repr=False)
|
||||
code_hash: OTPCodeHash = field(repr=False)
|
||||
status: HumanInputOTPChallengeStatus
|
||||
expires_at: UtcTimestamp
|
||||
resend_after: UtcTimestamp
|
||||
send_count: int
|
||||
attempt_count: int
|
||||
verified_at: UtcTimestamp | None
|
||||
invalidated_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_sha256(self.challenge_token_hash, label="challenge token hash")
|
||||
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
|
||||
raise ValueError("OTP challenge subject email must match the destination email")
|
||||
if not 1 <= self.send_count <= OTP_MAX_SEND_COUNT:
|
||||
raise ValueError("OTP send count is outside the supported range")
|
||||
if not 0 <= self.attempt_count <= OTP_MAX_ATTEMPT_COUNT:
|
||||
raise ValueError("OTP attempt count is outside the supported range")
|
||||
if self.resend_after.value != self.created_at.value + OTP_RESEND_COOLDOWN:
|
||||
raise ValueError("OTP resend_after must equal created_at plus the resend cooldown")
|
||||
if self.expires_at.value != self.created_at.value + OTP_EXPIRY:
|
||||
raise ValueError("OTP expires_at must equal created_at plus the expiry duration")
|
||||
if self.updated_at.value < self.created_at.value:
|
||||
raise ValueError("OTP updated_at must not precede created_at")
|
||||
if self.status is HumanInputOTPChallengeStatus.VERIFIED:
|
||||
if self.verified_at is None or self.invalidated_at is not None:
|
||||
raise ValueError("verified OTP challenge requires only verified_at")
|
||||
elif self.status is HumanInputOTPChallengeStatus.INVALIDATED:
|
||||
if self.invalidated_at is None or self.verified_at is not None:
|
||||
raise ValueError("invalidated OTP challenge requires only invalidated_at")
|
||||
elif self.verified_at is not None or self.invalidated_at is not None:
|
||||
raise ValueError("pending and expired OTP challenges cannot have terminal timestamps")
|
||||
|
||||
@classmethod
|
||||
def issue(
|
||||
cls,
|
||||
*,
|
||||
challenge_ref: OTPChallengeRef,
|
||||
subject: EmailOTPSubject,
|
||||
normalized_email: NormalizedEmail,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
send_count: int,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPChallenge:
|
||||
"""Hash a transient code and create one pending proof session."""
|
||||
|
||||
now = clock.now()
|
||||
return cls(
|
||||
ref=challenge_ref,
|
||||
subject=subject,
|
||||
normalized_email=normalized_email,
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
code_hash=code_hasher.hash_code(plaintext_code),
|
||||
status=HumanInputOTPChallengeStatus.PENDING,
|
||||
expires_at=UtcTimestamp(now.value + OTP_EXPIRY),
|
||||
resend_after=UtcTimestamp(now.value + OTP_RESEND_COOLDOWN),
|
||||
send_count=send_count,
|
||||
attempt_count=0,
|
||||
verified_at=None,
|
||||
invalidated_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def state_at(self, now: UtcTimestamp) -> OTPChallengeState:
|
||||
match self.status:
|
||||
case HumanInputOTPChallengeStatus.VERIFIED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.ALREADY_VERIFIED)
|
||||
case HumanInputOTPChallengeStatus.INVALIDATED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.INVALIDATED)
|
||||
case HumanInputOTPChallengeStatus.EXPIRED:
|
||||
return OTPChallengeState(self.status, OTPChallengeRejectionReason.EXPIRED)
|
||||
case HumanInputOTPChallengeStatus.PENDING:
|
||||
if now.value >= self.expires_at.value:
|
||||
return OTPChallengeState(HumanInputOTPChallengeStatus.EXPIRED, OTPChallengeRejectionReason.EXPIRED)
|
||||
return OTPChallengeState(self.status, None)
|
||||
raise AssertionError(f"unsupported OTP challenge status: {self.status}")
|
||||
|
||||
def replace(
|
||||
self,
|
||||
*,
|
||||
challenge_ref: OTPChallengeRef,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPReplacementDecision:
|
||||
"""Prepare an eligible replacement without persisting either state."""
|
||||
|
||||
now = clock.now()
|
||||
state = self.state_at(now)
|
||||
if (
|
||||
state.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
and self.status is HumanInputOTPChallengeStatus.PENDING
|
||||
):
|
||||
expired = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.EXPIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPReplacementDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
|
||||
if state.rejection is not None:
|
||||
return OTPReplacementDecision(self, None, state.rejection)
|
||||
if self.send_count >= OTP_MAX_SEND_COUNT:
|
||||
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.SEND_LIMIT_REACHED)
|
||||
if now.value < self.resend_after.value:
|
||||
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.RESEND_COOLDOWN)
|
||||
replacement_challenge = self.issue(
|
||||
challenge_ref=challenge_ref,
|
||||
subject=self.subject,
|
||||
normalized_email=self.normalized_email,
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
plaintext_code=plaintext_code,
|
||||
send_count=self.send_count + 1,
|
||||
clock=clock,
|
||||
code_hasher=code_hasher,
|
||||
)
|
||||
invalidated = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.INVALIDATED,
|
||||
invalidated_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPReplacementDecision(invalidated, replacement_challenge, None)
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
plaintext_code: str,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
) -> OTPVerificationDecision:
|
||||
"""Verify one transient code while preserving exact attempt boundaries."""
|
||||
|
||||
now = clock.now()
|
||||
state = self.state_at(now)
|
||||
if (
|
||||
state.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
and self.status is HumanInputOTPChallengeStatus.PENDING
|
||||
):
|
||||
expired = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.EXPIRED,
|
||||
updated_at=now,
|
||||
)
|
||||
return OTPVerificationDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
|
||||
if state.rejection is not None:
|
||||
return OTPVerificationDecision(self, None, state.rejection)
|
||||
if self.attempt_count >= OTP_MAX_ATTEMPT_COUNT:
|
||||
return OTPVerificationDecision(self, None, OTPChallengeRejectionReason.ATTEMPT_LIMIT_REACHED)
|
||||
attempt_count = self.attempt_count + 1
|
||||
if not code_hasher.verify_code(plaintext_code, self.code_hash):
|
||||
attempted = replace(self, attempt_count=attempt_count, updated_at=now)
|
||||
return OTPVerificationDecision(attempted, None, OTPChallengeRejectionReason.INVALID_CODE)
|
||||
verified = replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.VERIFIED,
|
||||
attempt_count=attempt_count,
|
||||
verified_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
proof = VerifiedEmailOTPProof(
|
||||
challenge_ref=self.ref,
|
||||
subject=self.subject,
|
||||
normalized_email=self.normalized_email,
|
||||
verified_at=now,
|
||||
)
|
||||
return OTPVerificationDecision(verified, proof, None)
|
||||
|
||||
def invalidate(self, *, clock: Clock) -> OTPChallenge:
|
||||
"""Make a pending proof session unusable without changing its counters."""
|
||||
|
||||
if self.status is not HumanInputOTPChallengeStatus.PENDING:
|
||||
return self
|
||||
now = clock.now()
|
||||
return replace(
|
||||
self,
|
||||
status=HumanInputOTPChallengeStatus.INVALIDATED,
|
||||
invalidated_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def to_public_primitive(self) -> OTPChallengePublicPrimitive:
|
||||
"""Return state diagnostics while deliberately excluding all hashes."""
|
||||
|
||||
return {
|
||||
"otp_challenge_id": str(self.ref.challenge_id),
|
||||
"form_id": str(self.ref.form_ref.form_id),
|
||||
"approver_grant_id": str(self.ref.grant_ref.grant_id),
|
||||
"status": self.status.value,
|
||||
"email": str(self.normalized_email),
|
||||
"send_count": self.send_count,
|
||||
"attempt_count": self.attempt_count,
|
||||
"expires_at": self.expires_at.to_primitive(),
|
||||
"resend_after": self.resend_after.to_primitive(),
|
||||
"verified_at": self.verified_at.to_primitive() if self.verified_at is not None else None,
|
||||
"invalidated_at": self.invalidated_at.to_primitive() if self.invalidated_at is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentEmailOTPIdentity:
|
||||
"""Coherent current grant subject and Email facts loaded by submission persistence."""
|
||||
|
||||
grant_ref: ApproverGrantRef
|
||||
subject: EmailOTPSubject | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailOTPProofAuthorizationDecision:
|
||||
"""OTP-specific proof decision consumed by the later Submission authorizer."""
|
||||
|
||||
proof: VerifiedEmailOTPProof | None
|
||||
rejection: OTPChallengeRejectionReason | None
|
||||
|
||||
|
||||
class OTPChallengeRepository(Protocol):
|
||||
"""Grant-scoped atomic persistence operations for OTP proof sessions."""
|
||||
|
||||
def issue_initial(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPChallenge: ...
|
||||
|
||||
def replace_current(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPReplacementDecision: ...
|
||||
|
||||
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision: ...
|
||||
|
||||
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None: ...
|
||||
|
||||
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None: ...
|
||||
|
||||
|
||||
def authorize_email_otp_proof(
|
||||
candidate: object,
|
||||
*,
|
||||
current_identity: CurrentEmailOTPIdentity,
|
||||
) -> EmailOTPProofAuthorizationDecision:
|
||||
"""Reject raw codes and stale identity incarnations without authorizing submission."""
|
||||
|
||||
if not isinstance(candidate, VerifiedEmailOTPProof):
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.RAW_CODE_NOT_VERIFIED)
|
||||
if candidate.challenge_ref.grant_ref != current_identity.grant_ref:
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.GRANT_MISMATCH)
|
||||
if (
|
||||
current_identity.subject is None
|
||||
or current_identity.normalized_email is None
|
||||
or candidate.subject != current_identity.subject
|
||||
or candidate.normalized_email != current_identity.normalized_email
|
||||
):
|
||||
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.STALE_IDENTITY)
|
||||
return EmailOTPProofAuthorizationDecision(candidate, None)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Operation-oriented persistence ports for the Human Input v2 form boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import HumanInputV2FormStatus
|
||||
from core.human_input_v2.shared import UtcTimestamp, WorkspaceId
|
||||
|
||||
from .delivery import DeliveryAttempt, DeliveryEndpoint, UploadCapability, UploadFileAssociation
|
||||
from .form import FormCreation, FrozenFormDefinition, HumanInputForm
|
||||
from .grants import ApproverGrant, DeliveryEndpointRef, FormRef
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormDefinitionProjection:
|
||||
"""Read model for rendering a form through one endpoint capability."""
|
||||
|
||||
form_ref: FormRef
|
||||
endpoint_ref: DeliveryEndpointRef
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
status: HumanInputV2FormStatus
|
||||
node_timeout_at: UtcTimestamp
|
||||
global_expires_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormDeliveryProjection:
|
||||
"""Read model containing only data needed to deliver one endpoint."""
|
||||
|
||||
form_ref: FormRef
|
||||
grant: ApproverGrant
|
||||
endpoint: DeliveryEndpoint
|
||||
definition: FrozenFormDefinition
|
||||
rendered_content: str
|
||||
|
||||
|
||||
class FormRepository(Protocol):
|
||||
"""Deep adapter contract whose operations own their query and transaction shape."""
|
||||
|
||||
def create_form(self, creation: FormCreation) -> HumanInputForm:
|
||||
"""Persist form, grants, and endpoints atomically."""
|
||||
|
||||
...
|
||||
|
||||
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
|
||||
"""Load the form and grants required for local transition decisions."""
|
||||
|
||||
...
|
||||
|
||||
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
|
||||
"""Load exactly one endpoint with its grant and form delivery values."""
|
||||
|
||||
...
|
||||
|
||||
def load_definition_by_endpoint_token(
|
||||
self,
|
||||
*,
|
||||
workspace_id: WorkspaceId,
|
||||
token_hash: str,
|
||||
) -> FormDefinitionProjection | None:
|
||||
"""Resolve a scoped interaction capability without creating authority."""
|
||||
|
||||
...
|
||||
|
||||
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
|
||||
"""Append one delivery fact without changing form lifecycle status."""
|
||||
|
||||
...
|
||||
|
||||
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
|
||||
"""Persist one endpoint-scoped upload capability."""
|
||||
|
||||
...
|
||||
|
||||
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
|
||||
"""Associate a file only after validating the full capability owner chain."""
|
||||
|
||||
...
|
||||
@@ -0,0 +1,775 @@
|
||||
"""Canonical approval plan values and single-entry recipient resolution.
|
||||
|
||||
``RecipientResolver.resolve`` is the only public operation that converts saved
|
||||
recipient specifications into approvers. Validation, Contact upgrade, subject
|
||||
deduplication, matched-source aggregation, debug replacement, and endpoint
|
||||
planning stay behind that interface so callers cannot apply them inconsistently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from hashlib import sha256
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactDirectoryPolicy,
|
||||
ContactDirectorySnapshot,
|
||||
ContactResolution,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel, IMProvider
|
||||
from core.human_input_v2.im_integration import EffectiveIMBindingSnapshot
|
||||
from core.human_input_v2.shared import (
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
|
||||
from .recipient_specifications import (
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
RecipientSpecification,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalSubjectKey:
|
||||
"""Portable form-scoped deduplication key, not an authorization identity."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
namespace, separator, identity = self.value.partition(":")
|
||||
valid_named_identity = namespace in {"contact", "end_user"} and bool(identity)
|
||||
valid_email_digest = (
|
||||
namespace == "email_address"
|
||||
and len(identity) == 64
|
||||
and all(character in "0123456789abcdef" for character in identity)
|
||||
)
|
||||
if not separator or not (valid_named_identity or valid_email_digest):
|
||||
raise ValueError("canonical subject key has an invalid portable format")
|
||||
|
||||
@classmethod
|
||||
def for_contact(cls, contact_id: ContactId) -> CanonicalSubjectKey:
|
||||
return cls(f"contact:{contact_id}")
|
||||
|
||||
@classmethod
|
||||
def for_end_user(cls, end_user_id: EndUserId) -> CanonicalSubjectKey:
|
||||
return cls(f"end_user:{end_user_id}")
|
||||
|
||||
@classmethod
|
||||
def for_email(cls, normalized_email: NormalizedEmail) -> CanonicalSubjectKey:
|
||||
digest = sha256(normalized_email.value.encode()).hexdigest()
|
||||
return cls(f"email_address:{digest}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactApprovalSubject:
|
||||
"""Approval authority backed by one canonical Contact."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.CONTACT
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "contact_id": self.contact_id.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserApprovalSubject:
|
||||
"""Approval authority backed by one app-scoped EndUser."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.END_USER
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "end_user_id": self.end_user_id.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressApprovalSubject:
|
||||
"""Task-scoped approval authority backed by one normalized Email address."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
@property
|
||||
def subject_type(self) -> HumanInputApproverGrantSubjectType:
|
||||
return HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": self.subject_type.value, "normalized_email": self.normalized_email.to_primitive()}
|
||||
|
||||
|
||||
type ApprovalSubject = ContactApprovalSubject | EndUserApprovalSubject | EmailAddressApprovalSubject
|
||||
|
||||
|
||||
class RecipientSourceKind(StrEnum):
|
||||
"""Stable source discriminator retained after canonicalization."""
|
||||
|
||||
STATIC_CONTACT = "static_contact"
|
||||
ONE_TIME_EMAIL = "one_time_email"
|
||||
DYNAMIC_EMAIL = "dynamic_email"
|
||||
CURRENT_INITIATOR = "current_initiator"
|
||||
DEBUG_REPLACEMENT = "debug_replacement"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MatchedRecipientSource:
|
||||
"""One ordered configured or request-scoped source of an approver."""
|
||||
|
||||
kind: RecipientSourceKind
|
||||
position: int
|
||||
reference: str | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.position < 0:
|
||||
raise ValueError("recipient source position must not be negative")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"kind": self.kind.value, "position": self.position, "reference": self.reference}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubjectSnapshot:
|
||||
"""Display-only identity facts captured by resolution."""
|
||||
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"display_name": self.display_name, "email": self.email}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailEndpointPlan:
|
||||
"""Email delivery destination for one canonical approver."""
|
||||
|
||||
email_address: NormalizedEmail
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.EMAIL
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value, "email_address": self.email_address.to_primitive()}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMEndpointPlan:
|
||||
"""Credential-free IM delivery destination frozen from an effective binding."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.IM
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"channel": self.channel.value,
|
||||
"integration_id": self.integration_id.to_primitive(),
|
||||
"provider": self.provider.value,
|
||||
"provider_tenant_id": self.provider_tenant_id,
|
||||
"identity_id": self.identity_id.to_primitive(),
|
||||
"binding_id": self.binding_id.to_primitive() if self.binding_id is not None else None,
|
||||
"provider_user_id": self.provider_user_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WebEndpointPlan:
|
||||
"""Public or trusted-app web interaction surface without a saved token."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.WEB
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsoleEndpointPlan:
|
||||
"""Authenticated console interaction surface without a notification address."""
|
||||
|
||||
@property
|
||||
def channel(self) -> HumanInputDeliveryChannel:
|
||||
return HumanInputDeliveryChannel.CONSOLE
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"channel": self.channel.value}
|
||||
|
||||
|
||||
type DeliveryEndpointPlan = EmailEndpointPlan | IMEndpointPlan | WebEndpointPlan | ConsoleEndpointPlan
|
||||
|
||||
|
||||
class RecipientRejectionReason(StrEnum):
|
||||
"""Transport-neutral reason for rejecting one recipient source."""
|
||||
|
||||
INVALID_CONTACT_ID = "invalid_contact_id"
|
||||
CONTACT_UNAVAILABLE = "contact_unavailable"
|
||||
INVALID_DYNAMIC_SELECTOR = "invalid_dynamic_selector"
|
||||
DYNAMIC_VALUE_UNAVAILABLE = "dynamic_value_unavailable"
|
||||
UNSUPPORTED_DYNAMIC_TYPE = "unsupported_dynamic_type"
|
||||
INVALID_EMAIL = "invalid_email"
|
||||
INITIATOR_UNAVAILABLE = "initiator_unavailable"
|
||||
NO_USABLE_ENDPOINT = "no_usable_endpoint"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RejectedRecipient:
|
||||
"""Machine-readable source failure retained alongside valid approvers."""
|
||||
|
||||
source: MatchedRecipientSource
|
||||
reason: RecipientRejectionReason
|
||||
rejected_value: str | None
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"source": self.source.to_primitive(),
|
||||
"reason": self.reason.value,
|
||||
"rejected_value": self.rejected_value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedApprover:
|
||||
"""One canonical subject with all matched sources and usable endpoints."""
|
||||
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
matched_sources: tuple[MatchedRecipientSource, ...]
|
||||
subject_snapshot: SubjectSnapshot
|
||||
endpoints: tuple[DeliveryEndpointPlan, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.matched_sources, tuple) or not isinstance(self.endpoints, tuple):
|
||||
raise TypeError("resolved approver collections must be immutable tuples")
|
||||
if self.subject_key != _canonical_key_for_subject(self.subject):
|
||||
raise ValueError("resolved approver subject key does not match its subject")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"subject": self.subject.to_primitive(),
|
||||
"subject_key": self.subject_key.value,
|
||||
"matched_sources": [source.to_primitive() for source in self.matched_sources],
|
||||
"subject_snapshot": self.subject_snapshot.to_primitive(),
|
||||
"endpoints": [endpoint.to_primitive() for endpoint in self.endpoints],
|
||||
}
|
||||
|
||||
|
||||
class RecipientResolutionFailureReason(StrEnum):
|
||||
"""Stable whole-plan failure independent from HTTP or provider semantics."""
|
||||
|
||||
NO_VALID_RECIPIENTS = "no_valid_recipients"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedApprovalPlan:
|
||||
"""Immutable complete output of one recipient resolution request."""
|
||||
|
||||
approvers: tuple[ResolvedApprover, ...]
|
||||
rejected_recipients: tuple[RejectedRecipient, ...]
|
||||
failure_reason: RecipientResolutionFailureReason | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.approvers, tuple) or not isinstance(self.rejected_recipients, tuple):
|
||||
raise TypeError("approval plan collections must be immutable tuples")
|
||||
if self.approvers and self.failure_reason is not None:
|
||||
raise ValueError("a plan with approvers cannot have a failure reason")
|
||||
if not self.approvers and self.failure_reason is None:
|
||||
raise ValueError("a plan without approvers must have a failure reason")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {
|
||||
"approvers": [approver.to_primitive() for approver in self.approvers],
|
||||
"rejected_recipients": [rejection.to_primitive() for rejection in self.rejected_recipients],
|
||||
"failure_reason": self.failure_reason.value if self.failure_reason is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactInitiatorSnapshot:
|
||||
"""Current request initiator resolved to a canonical Contact reference."""
|
||||
|
||||
contact_id: ContactId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserInitiatorSnapshot:
|
||||
"""Current request initiator resolved to one app-scoped EndUser."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
type InitiatorSnapshot = ContactInitiatorSnapshot | EndUserInitiatorSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DebugRecipientReplacement:
|
||||
"""Valid debug actor that replaces saved recipients for one request only."""
|
||||
|
||||
subject: InitiatorSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryCapabilitySnapshot:
|
||||
"""Request-scoped effective delivery and interaction capabilities.
|
||||
|
||||
IM values are already resolved by the IM control plane; this domain never
|
||||
sees credentials, raw provider clients, or invalid binding candidates.
|
||||
Explicit Web/Console sets prevent recipient resolution from inventing
|
||||
interaction surfaces that the current runtime cannot actually expose.
|
||||
"""
|
||||
|
||||
im_bindings: tuple[EffectiveIMBindingSnapshot, ...] = ()
|
||||
contact_web_ids: frozenset[ContactId] = frozenset()
|
||||
contact_console_ids: frozenset[ContactId] = frozenset()
|
||||
end_user_web_ids: frozenset[EndUserId] = frozenset()
|
||||
email_address_web_available: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.im_bindings, tuple):
|
||||
raise TypeError("effective IM bindings must be an immutable tuple")
|
||||
if not all(
|
||||
isinstance(values, frozenset)
|
||||
for values in (self.contact_web_ids, self.contact_console_ids, self.end_user_web_ids)
|
||||
):
|
||||
raise TypeError("interaction capability identifiers must be immutable frozensets")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PendingApprover:
|
||||
"""Private mutable accumulator hidden behind the immutable resolver result."""
|
||||
|
||||
subject: ApprovalSubject
|
||||
subject_key: CanonicalSubjectKey
|
||||
subject_snapshot: SubjectSnapshot
|
||||
first_source_position: int
|
||||
matched_sources: list[MatchedRecipientSource] = field(default_factory=list)
|
||||
endpoints: list[DeliveryEndpointPlan] = field(default_factory=list)
|
||||
|
||||
|
||||
class RecipientResolver:
|
||||
"""Resolve all recipient semantics through one deterministic domain entry."""
|
||||
|
||||
@staticmethod
|
||||
def resolve(
|
||||
*,
|
||||
specifications: tuple[RecipientSpecification, ...],
|
||||
directory: ContactDirectorySnapshot,
|
||||
dynamic_values: tuple[DynamicRecipientValue, ...],
|
||||
initiator: InitiatorSnapshot | None,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
debug_replacement: DebugRecipientReplacement | None = None,
|
||||
) -> ResolvedApprovalPlan:
|
||||
"""Resolve immutable request inputs into one complete approval plan.
|
||||
|
||||
Invalid or unavailable sources are returned as typed rejection facts;
|
||||
the method raises only when a caller violates an immutable input shape.
|
||||
No database, provider, transport, or mutation side effects occur.
|
||||
"""
|
||||
if not isinstance(specifications, tuple) or not isinstance(dynamic_values, tuple):
|
||||
raise TypeError("recipient resolution inputs must be immutable tuples")
|
||||
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover] = {}
|
||||
rejected_recipients: list[RejectedRecipient] = []
|
||||
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue] = {}
|
||||
for dynamic_value in dynamic_values:
|
||||
dynamic_values_by_selector.setdefault(dynamic_value.selector, dynamic_value)
|
||||
|
||||
if debug_replacement is not None:
|
||||
source = MatchedRecipientSource(RecipientSourceKind.DEBUG_REPLACEMENT, 0, None)
|
||||
RecipientResolver._resolve_initiator(
|
||||
debug_replacement.subject,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
else:
|
||||
for position, specification in enumerate(specifications):
|
||||
RecipientResolver._resolve_specification(
|
||||
specification,
|
||||
position,
|
||||
directory,
|
||||
dynamic_values_by_selector,
|
||||
initiator,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
|
||||
approvers: list[ResolvedApprover] = []
|
||||
for pending in sorted(
|
||||
pending_approvers.values(),
|
||||
key=lambda candidate: (candidate.first_source_position, candidate.subject_key.value),
|
||||
):
|
||||
matched_sources = tuple(sorted(pending.matched_sources, key=_source_sort_key))
|
||||
endpoints = tuple(sorted(pending.endpoints, key=_endpoint_sort_key))
|
||||
if not endpoints:
|
||||
rejected_recipients.extend(
|
||||
RejectedRecipient(
|
||||
source=source,
|
||||
reason=RecipientRejectionReason.NO_USABLE_ENDPOINT,
|
||||
rejected_value=pending.subject_key.value,
|
||||
)
|
||||
for source in matched_sources
|
||||
)
|
||||
continue
|
||||
approvers.append(
|
||||
ResolvedApprover(
|
||||
subject=pending.subject,
|
||||
subject_key=pending.subject_key,
|
||||
matched_sources=matched_sources,
|
||||
subject_snapshot=pending.subject_snapshot,
|
||||
endpoints=endpoints,
|
||||
)
|
||||
)
|
||||
|
||||
ordered_rejections = tuple(sorted(rejected_recipients, key=_rejection_sort_key))
|
||||
failure_reason = None if approvers else RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
return ResolvedApprovalPlan(tuple(approvers), ordered_rejections, failure_reason)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_specification(
|
||||
specification: RecipientSpecification,
|
||||
position: int,
|
||||
directory: ContactDirectorySnapshot,
|
||||
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue],
|
||||
initiator: InitiatorSnapshot | None,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
if isinstance(specification, ContactRecipientSpecification):
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.STATIC_CONTACT,
|
||||
position,
|
||||
specification.contact_id,
|
||||
)
|
||||
try:
|
||||
contact_id = ContactId(specification.contact_id)
|
||||
except ValueError:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INVALID_CONTACT_ID, specification.contact_id)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_contact(
|
||||
contact_id,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, OneTimeEmailRecipientSpecification):
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.ONE_TIME_EMAIL,
|
||||
position,
|
||||
specification.email,
|
||||
)
|
||||
RecipientResolver._resolve_email(
|
||||
specification.email,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, DynamicEmailRecipientSpecification):
|
||||
selector_reference = ".".join(specification.selector)
|
||||
source = MatchedRecipientSource(
|
||||
RecipientSourceKind.DYNAMIC_EMAIL,
|
||||
position,
|
||||
selector_reference,
|
||||
)
|
||||
if not specification.selector or any(not component.strip() for component in specification.selector):
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INVALID_DYNAMIC_SELECTOR, selector_reference)
|
||||
)
|
||||
return
|
||||
dynamic_value = dynamic_values_by_selector.get(specification.selector)
|
||||
if dynamic_value is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.DYNAMIC_VALUE_UNAVAILABLE, selector_reference)
|
||||
)
|
||||
return
|
||||
if isinstance(dynamic_value.value, UnsupportedDynamicRecipientValue):
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(
|
||||
source,
|
||||
RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE,
|
||||
dynamic_value.value.value_type,
|
||||
)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_email(
|
||||
dynamic_value.value,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(specification, CurrentInitiatorRecipientSpecification):
|
||||
source = MatchedRecipientSource(RecipientSourceKind.CURRENT_INITIATOR, position, None)
|
||||
if initiator is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.INITIATOR_UNAVAILABLE, None)
|
||||
)
|
||||
return
|
||||
RecipientResolver._resolve_initiator(
|
||||
initiator,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
assert_never(specification)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_initiator(
|
||||
initiator: InitiatorSnapshot,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
if isinstance(initiator, ContactInitiatorSnapshot):
|
||||
RecipientResolver._resolve_contact(
|
||||
initiator.contact_id,
|
||||
source,
|
||||
directory,
|
||||
capabilities,
|
||||
pending_approvers,
|
||||
rejected_recipients,
|
||||
)
|
||||
return
|
||||
|
||||
normalized_email: NormalizedEmail | None = None
|
||||
if initiator.email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(initiator.email)
|
||||
except ValueError:
|
||||
normalized_email = None
|
||||
subject = EndUserApprovalSubject(initiator.end_user_id)
|
||||
endpoints: list[DeliveryEndpointPlan] = []
|
||||
if normalized_email is not None:
|
||||
endpoints.append(EmailEndpointPlan(normalized_email))
|
||||
if initiator.end_user_id in capabilities.end_user_web_ids:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
subject,
|
||||
source,
|
||||
SubjectSnapshot(initiator.display_name, normalized_email.value if normalized_email is not None else None),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_contact(
|
||||
contact_id: ContactId,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
try:
|
||||
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact_id)
|
||||
except ContactDirectoryError:
|
||||
resolution = ContactResolution.ABSENT
|
||||
contact = directory.find(contact_id)
|
||||
if resolution is ContactResolution.ABSENT or contact is None:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(source, RecipientRejectionReason.CONTACT_UNAVAILABLE, contact_id.value)
|
||||
)
|
||||
return
|
||||
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_email(
|
||||
email: str,
|
||||
source: MatchedRecipientSource,
|
||||
directory: ContactDirectorySnapshot,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
rejected_recipients: list[RejectedRecipient],
|
||||
) -> None:
|
||||
"""Resolve Email input without bypassing an existing Contact's policy.
|
||||
|
||||
EmailAddress authority is valid only when the directory contains no
|
||||
matching Contact. A matching but unavailable Contact fails closed so
|
||||
callers cannot bypass Account availability or workspace visibility.
|
||||
"""
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError:
|
||||
rejected_recipients.append(RejectedRecipient(source, RecipientRejectionReason.INVALID_EMAIL, email))
|
||||
return
|
||||
|
||||
matching_contacts = sorted(
|
||||
(contact for contact in directory.contacts if contact.normalized_email == normalized_email),
|
||||
key=lambda contact: contact.id.value,
|
||||
)
|
||||
for contact in matching_contacts:
|
||||
try:
|
||||
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact.id)
|
||||
except ContactDirectoryError:
|
||||
continue
|
||||
if resolution is not ContactResolution.ABSENT:
|
||||
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
|
||||
return
|
||||
|
||||
if matching_contacts:
|
||||
rejected_recipients.append(
|
||||
RejectedRecipient(
|
||||
source,
|
||||
RecipientRejectionReason.CONTACT_UNAVAILABLE,
|
||||
normalized_email.value,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
subject = EmailAddressApprovalSubject(normalized_email)
|
||||
endpoints: list[DeliveryEndpointPlan] = [EmailEndpointPlan(normalized_email)]
|
||||
if capabilities.email_address_web_available:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
subject,
|
||||
source,
|
||||
SubjectSnapshot(None, normalized_email.value),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_contact_approver(
|
||||
contact: Contact,
|
||||
source: MatchedRecipientSource,
|
||||
capabilities: DeliveryCapabilitySnapshot,
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
) -> None:
|
||||
endpoints: list[DeliveryEndpointPlan] = []
|
||||
if contact.normalized_email is not None:
|
||||
endpoints.append(EmailEndpointPlan(contact.normalized_email))
|
||||
endpoints.extend(
|
||||
IMEndpointPlan(
|
||||
integration_id=binding.integration_id,
|
||||
provider=binding.provider,
|
||||
provider_tenant_id=binding.provider_tenant_id,
|
||||
identity_id=binding.identity_id,
|
||||
binding_id=binding.binding_id,
|
||||
provider_user_id=binding.provider_user_id,
|
||||
)
|
||||
for binding in capabilities.im_bindings
|
||||
if binding.contact_id == contact.id
|
||||
)
|
||||
if contact.id in capabilities.contact_web_ids:
|
||||
endpoints.append(WebEndpointPlan())
|
||||
if contact.id in capabilities.contact_console_ids:
|
||||
endpoints.append(ConsoleEndpointPlan())
|
||||
RecipientResolver._add_approver(
|
||||
ContactApprovalSubject(contact.id),
|
||||
source,
|
||||
SubjectSnapshot(contact.name, contact.email),
|
||||
endpoints,
|
||||
pending_approvers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _add_approver(
|
||||
subject: ApprovalSubject,
|
||||
source: MatchedRecipientSource,
|
||||
subject_snapshot: SubjectSnapshot,
|
||||
endpoints: list[DeliveryEndpointPlan],
|
||||
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
|
||||
) -> None:
|
||||
subject_key = _canonical_key_for_subject(subject)
|
||||
pending = pending_approvers.get(subject_key)
|
||||
if pending is None:
|
||||
pending = _PendingApprover(
|
||||
subject=subject,
|
||||
subject_key=subject_key,
|
||||
subject_snapshot=subject_snapshot,
|
||||
first_source_position=source.position,
|
||||
)
|
||||
pending_approvers[subject_key] = pending
|
||||
if source not in pending.matched_sources:
|
||||
pending.matched_sources.append(source)
|
||||
for endpoint in endpoints:
|
||||
if endpoint not in pending.endpoints:
|
||||
pending.endpoints.append(endpoint)
|
||||
|
||||
|
||||
def _canonical_key_for_subject(subject: ApprovalSubject) -> CanonicalSubjectKey:
|
||||
if isinstance(subject, ContactApprovalSubject):
|
||||
return CanonicalSubjectKey.for_contact(subject.contact_id)
|
||||
if isinstance(subject, EndUserApprovalSubject):
|
||||
return CanonicalSubjectKey.for_end_user(subject.end_user_id)
|
||||
return CanonicalSubjectKey.for_email(subject.normalized_email)
|
||||
|
||||
|
||||
_CHANNEL_ORDER = {
|
||||
HumanInputDeliveryChannel.EMAIL: 0,
|
||||
HumanInputDeliveryChannel.IM: 1,
|
||||
HumanInputDeliveryChannel.WEB: 2,
|
||||
HumanInputDeliveryChannel.CONSOLE: 3,
|
||||
}
|
||||
|
||||
|
||||
def _source_sort_key(source: MatchedRecipientSource) -> tuple[int, str, str]:
|
||||
return source.position, source.kind.value, source.reference or ""
|
||||
|
||||
|
||||
def _endpoint_sort_key(endpoint: DeliveryEndpointPlan) -> tuple[int, str, str, str]:
|
||||
channel_order = _CHANNEL_ORDER[endpoint.channel]
|
||||
if isinstance(endpoint, EmailEndpointPlan):
|
||||
return channel_order, endpoint.email_address.value, "", ""
|
||||
if isinstance(endpoint, IMEndpointPlan):
|
||||
return (
|
||||
channel_order,
|
||||
endpoint.integration_id.value,
|
||||
endpoint.provider.value,
|
||||
endpoint.identity_id.value,
|
||||
)
|
||||
return channel_order, "", "", ""
|
||||
|
||||
|
||||
def _rejection_sort_key(rejection: RejectedRecipient) -> tuple[int, str, str, str]:
|
||||
source_key = _source_sort_key(rejection.source)
|
||||
return source_key[0], source_key[1], rejection.reason.value, rejection.rejected_value or ""
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Immutable recipient specifications at the workflow-to-approval boundary.
|
||||
|
||||
Saved node configuration intentionally retains unvalidated Email text. Runtime
|
||||
validation belongs to :class:`RecipientResolver`, which can retain a typed
|
||||
rejection without making workflow configuration parsing fail early.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import assert_never
|
||||
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
Contact as WorkflowContactRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
DynamicEmail as WorkflowDynamicEmailRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
HumanInputNodeData,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
Initiator as WorkflowInitiatorRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
OnetimeEmail as WorkflowOneTimeEmailRecipient,
|
||||
)
|
||||
|
||||
|
||||
class RecipientSpecificationKind(StrEnum):
|
||||
"""Stable workflow recipient discriminator."""
|
||||
|
||||
CONTACT = "contact"
|
||||
DYNAMIC_EMAIL = "dynamic_email"
|
||||
ONETIME_EMAIL = "onetime_email"
|
||||
INITIATOR = "initiator"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactRecipientSpecification:
|
||||
"""Saved reference to one Contact; current availability is resolved later."""
|
||||
|
||||
contact_id: str
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.CONTACT.value, "contact_id": self.contact_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OneTimeEmailRecipientSpecification:
|
||||
"""Saved one-time Email text whose validity is decided per resolution."""
|
||||
|
||||
email: str
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.ONETIME_EMAIL.value, "email": self.email}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DynamicEmailRecipientSpecification:
|
||||
"""Saved workflow selector whose current value is supplied separately."""
|
||||
|
||||
selector: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.selector, tuple):
|
||||
raise TypeError("dynamic email selector must be an immutable tuple")
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.DYNAMIC_EMAIL.value, "selector": list(self.selector)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentInitiatorRecipientSpecification:
|
||||
"""Request-scoped current initiator recipient marker."""
|
||||
|
||||
def to_primitive(self) -> dict[str, object]:
|
||||
return {"type": RecipientSpecificationKind.INITIATOR.value}
|
||||
|
||||
|
||||
type RecipientSpecification = (
|
||||
ContactRecipientSpecification
|
||||
| OneTimeEmailRecipientSpecification
|
||||
| DynamicEmailRecipientSpecification
|
||||
| CurrentInitiatorRecipientSpecification
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnsupportedDynamicRecipientValue:
|
||||
"""Safe snapshot of a non-string workflow value without retaining its graph."""
|
||||
|
||||
value_type: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.value_type:
|
||||
raise ValueError("unsupported dynamic recipient value type must not be blank")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DynamicRecipientValue:
|
||||
"""One evaluated selector value captured for a single resolution request."""
|
||||
|
||||
selector: tuple[str, ...]
|
||||
value: str | UnsupportedDynamicRecipientValue
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.selector, tuple):
|
||||
raise TypeError("dynamic recipient value selector must be an immutable tuple")
|
||||
|
||||
@classmethod
|
||||
def from_runtime(cls, selector: tuple[str, ...], value: object) -> DynamicRecipientValue:
|
||||
"""Capture a runtime value without retaining mutable unsupported data."""
|
||||
|
||||
captured_value: str | UnsupportedDynamicRecipientValue
|
||||
if isinstance(value, str):
|
||||
captured_value = value
|
||||
else:
|
||||
captured_value = UnsupportedDynamicRecipientValue(type(value).__name__)
|
||||
return cls(selector=selector, value=captured_value)
|
||||
|
||||
|
||||
class WorkflowRecipientSpecificationAdapter:
|
||||
"""Convert versioned workflow node values into approval-domain inputs."""
|
||||
|
||||
@staticmethod
|
||||
def from_node_data(node_data: HumanInputNodeData) -> tuple[RecipientSpecification, ...]:
|
||||
"""Copy ordered v2 node recipients into immutable domain values."""
|
||||
|
||||
specifications: list[RecipientSpecification] = []
|
||||
for configured_recipient in node_data.recipients_spec:
|
||||
specification: RecipientSpecification
|
||||
if isinstance(configured_recipient, WorkflowContactRecipient):
|
||||
specification = ContactRecipientSpecification(contact_id=configured_recipient.contact_id)
|
||||
elif isinstance(configured_recipient, WorkflowOneTimeEmailRecipient):
|
||||
specification = OneTimeEmailRecipientSpecification(email=configured_recipient.email)
|
||||
elif isinstance(configured_recipient, WorkflowDynamicEmailRecipient):
|
||||
specification = DynamicEmailRecipientSpecification(selector=tuple(configured_recipient.selector))
|
||||
elif isinstance(configured_recipient, WorkflowInitiatorRecipient):
|
||||
specification = CurrentInitiatorRecipientSpecification()
|
||||
else:
|
||||
assert_never(configured_recipient)
|
||||
specifications.append(specification)
|
||||
return tuple(specifications)
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Pure current-state authorization for Human Input v2 submissions.
|
||||
|
||||
The module owns the cross-snapshot decision only. Callers must verify transport
|
||||
credentials before constructing a proof and must load one coherent
|
||||
``AuthorizationContext`` through persistence. The authorizer performs no I/O,
|
||||
does not retain raw credentials, and never reloads Contact or IM binding facts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .delivery import DeliveryEndpoint
|
||||
from .form import (
|
||||
FormInactiveReason,
|
||||
HumanInputForm,
|
||||
InactiveFormState,
|
||||
InvalidApproverGrantError,
|
||||
InvalidSelectedActionError,
|
||||
SubmissionTransitionDecision,
|
||||
)
|
||||
from .grants import ApproverGrant, DeliveryEndpointRef
|
||||
from .otp import ContactOTPSubject, EmailAddressOTPSubject, VerifiedEmailOTPProof
|
||||
from .recipient_resolution import ContactApprovalSubject, EmailAddressApprovalSubject, EndUserApprovalSubject
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedAccountSessionProof:
|
||||
"""Current Account identity produced by a trusted session verifier."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedTrustedEndUserProof:
|
||||
"""Current EndUser identity produced by a trusted app-token boundary."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
app_id: AppId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VerifiedIMIdentityProof:
|
||||
"""Current provider identity evidence without callback credentials or payloads."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
provider_user_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id.strip() or not self.provider_user_id.strip():
|
||||
raise ValueError("verified IM provider identities must not be blank")
|
||||
|
||||
|
||||
type VerifiedSubmissionProof = (
|
||||
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountSubmissionActor:
|
||||
"""Current Dify Account that completed a submission."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EndUserSubmissionActor:
|
||||
"""Current app-scoped EndUser that completed a submission."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmailAddressSubmissionActor:
|
||||
"""Verified normalized Email identity that completed a submission."""
|
||||
|
||||
normalized_email: NormalizedEmail
|
||||
|
||||
|
||||
type SubmissionActor = AccountSubmissionActor | EndUserSubmissionActor | EmailAddressSubmissionActor
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentContactAuthorizationFacts:
|
||||
"""Current Contact incarnation, Email, Account, and workspace availability."""
|
||||
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
account_active: bool
|
||||
workspace_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentEndUserAuthorizationFacts:
|
||||
"""Current tenant/app ownership facts for one EndUser identity."""
|
||||
|
||||
end_user_id: EndUserId
|
||||
app_id: AppId
|
||||
workspace_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentIMAuthorizationFacts:
|
||||
"""Credential-free effective IM binding observed in the authorization snapshot."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizationContext:
|
||||
"""One immutable tenant-scoped view used without later identity reloads."""
|
||||
|
||||
form: HumanInputForm
|
||||
grant: ApproverGrant
|
||||
endpoint: DeliveryEndpoint | None
|
||||
current_contact: CurrentContactAuthorizationFacts | None
|
||||
current_end_user: CurrentEndUserAuthorizationFacts | None
|
||||
current_im_binding: CurrentIMAuthorizationFacts | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.grant.ref.form_ref != self.form.ref or self.grant not in self.form.grants:
|
||||
raise ValueError("authorization grant does not belong to the form snapshot")
|
||||
if self.endpoint is not None and self.endpoint.grant_ref != self.grant.ref:
|
||||
raise ValueError("authorization endpoint does not belong to the target grant")
|
||||
|
||||
|
||||
class SubmissionAuthorizationRejection(StrEnum):
|
||||
"""Stable transport-neutral reasons for denied submission authority."""
|
||||
|
||||
RAW_CREDENTIAL_NOT_VERIFIED = "raw_credential_not_verified"
|
||||
FORM_ALREADY_SUBMITTED = "form_already_submitted"
|
||||
FORM_TIMED_OUT = "form_timed_out"
|
||||
FORM_STATUS_EXPIRED = "form_status_expired"
|
||||
FORM_GLOBALLY_EXPIRED = "form_globally_expired"
|
||||
GRANT_NOT_MATCHED = "grant_not_matched"
|
||||
INVALID_SELECTED_ACTION = "invalid_selected_action"
|
||||
STALE_IDENTITY = "stale_identity"
|
||||
ACCOUNT_DISABLED = "account_disabled"
|
||||
WORKSPACE_UNAVAILABLE = "workspace_unavailable"
|
||||
END_USER_UNAVAILABLE = "end_user_unavailable"
|
||||
IM_BINDING_CHANGED = "im_binding_changed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizedSubmission:
|
||||
"""Current authority and local form transition prepared for atomic persistence."""
|
||||
|
||||
transition: SubmissionTransitionDecision
|
||||
proof: VerifiedSubmissionProof
|
||||
actor: SubmissionActor
|
||||
endpoint_ref: DeliveryEndpointRef | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionAuthorizationDecision:
|
||||
"""Exactly one authorized value or stable rejection."""
|
||||
|
||||
authorized: AuthorizedSubmission | None
|
||||
rejection: SubmissionAuthorizationRejection | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (self.authorized is None) == (self.rejection is None):
|
||||
raise ValueError("authorization decision requires exactly one outcome")
|
||||
|
||||
@classmethod
|
||||
def accept(cls, authorized: AuthorizedSubmission) -> SubmissionAuthorizationDecision:
|
||||
return cls(authorized, None)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: SubmissionAuthorizationRejection) -> SubmissionAuthorizationDecision:
|
||||
return cls(None, reason)
|
||||
|
||||
|
||||
class SubmissionAuthorizer:
|
||||
"""Stateless cross-snapshot policy that resolves one current business actor."""
|
||||
|
||||
@classmethod
|
||||
def authorize(
|
||||
cls,
|
||||
*,
|
||||
context: AuthorizationContext,
|
||||
proof: object,
|
||||
selected_action_id: str,
|
||||
now: UtcTimestamp,
|
||||
) -> SubmissionAuthorizationDecision:
|
||||
"""Authorize verified proof against one already-loaded coherent context."""
|
||||
|
||||
if not isinstance(
|
||||
proof,
|
||||
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof,
|
||||
):
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED)
|
||||
|
||||
state = context.form.state_at(now)
|
||||
if isinstance(state, InactiveFormState):
|
||||
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(state.reason))
|
||||
try:
|
||||
transition = context.form.decide_submission(
|
||||
grant_id=context.grant.id,
|
||||
selected_action_id=selected_action_id,
|
||||
now=now,
|
||||
)
|
||||
except InvalidApproverGrantError:
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.GRANT_NOT_MATCHED)
|
||||
except InvalidSelectedActionError:
|
||||
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.INVALID_SELECTED_ACTION)
|
||||
if isinstance(transition, InactiveFormState):
|
||||
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(transition.reason))
|
||||
|
||||
actor_or_rejection = cls._resolve_actor(context, proof)
|
||||
if isinstance(actor_or_rejection, SubmissionAuthorizationRejection):
|
||||
return SubmissionAuthorizationDecision.reject(actor_or_rejection)
|
||||
return SubmissionAuthorizationDecision.accept(
|
||||
AuthorizedSubmission(
|
||||
transition=transition,
|
||||
proof=proof,
|
||||
actor=actor_or_rejection,
|
||||
endpoint_ref=context.endpoint.ref if context.endpoint is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _inactive_rejection(reason: FormInactiveReason) -> SubmissionAuthorizationRejection:
|
||||
match reason:
|
||||
case FormInactiveReason.SUBMITTED:
|
||||
return SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED
|
||||
case FormInactiveReason.TIMED_OUT:
|
||||
return SubmissionAuthorizationRejection.FORM_TIMED_OUT
|
||||
case FormInactiveReason.STATUS_EXPIRED:
|
||||
return SubmissionAuthorizationRejection.FORM_STATUS_EXPIRED
|
||||
case FormInactiveReason.GLOBALLY_EXPIRED:
|
||||
return SubmissionAuthorizationRejection.FORM_GLOBALLY_EXPIRED
|
||||
assert_never(reason)
|
||||
|
||||
@classmethod
|
||||
def _resolve_actor(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedSubmissionProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
match proof:
|
||||
case VerifiedAccountSessionProof():
|
||||
return cls._authorize_account(context, proof)
|
||||
case VerifiedTrustedEndUserProof():
|
||||
return cls._authorize_end_user(context, proof)
|
||||
case VerifiedEmailOTPProof():
|
||||
return cls._authorize_email(context, proof)
|
||||
case VerifiedIMIdentityProof():
|
||||
return cls._authorize_im(context, proof)
|
||||
assert_never(proof)
|
||||
|
||||
@classmethod
|
||||
def _authorize_account(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedAccountSessionProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, ContactApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = cls._validate_current_contact(context, context.grant.subject.contact_id)
|
||||
if isinstance(current, SubmissionAuthorizationRejection):
|
||||
return current
|
||||
if current.account_id != proof.account_id:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
return AccountSubmissionActor(proof.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _authorize_end_user(
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedTrustedEndUserProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, EndUserApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = context.current_end_user
|
||||
if (
|
||||
current is None
|
||||
or current.end_user_id != context.grant.subject.end_user_id
|
||||
or current.end_user_id != proof.end_user_id
|
||||
or current.app_id != context.form.app_id
|
||||
or current.app_id != proof.app_id
|
||||
):
|
||||
return SubmissionAuthorizationRejection.END_USER_UNAVAILABLE
|
||||
if not current.workspace_available:
|
||||
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
|
||||
return EndUserSubmissionActor(current.end_user_id)
|
||||
|
||||
@classmethod
|
||||
def _authorize_email(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedEmailOTPProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if proof.challenge_ref.grant_ref != context.grant.ref:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
subject = context.grant.subject
|
||||
if isinstance(subject, EmailAddressApprovalSubject):
|
||||
if (
|
||||
not isinstance(proof.subject, EmailAddressOTPSubject)
|
||||
or proof.subject.normalized_email != subject.normalized_email
|
||||
or proof.normalized_email != subject.normalized_email
|
||||
):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
return EmailAddressSubmissionActor(subject.normalized_email)
|
||||
if not isinstance(subject, ContactApprovalSubject) or not isinstance(proof.subject, ContactOTPSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
if proof.subject.contact_id != subject.contact_id:
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current = cls._validate_current_contact(context, subject.contact_id)
|
||||
if isinstance(current, SubmissionAuthorizationRejection):
|
||||
return current
|
||||
if current.normalized_email is None or current.normalized_email != proof.normalized_email:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
if current.account_id is not None:
|
||||
return AccountSubmissionActor(current.account_id)
|
||||
return EmailAddressSubmissionActor(proof.normalized_email)
|
||||
|
||||
@classmethod
|
||||
def _authorize_im(
|
||||
cls,
|
||||
context: AuthorizationContext,
|
||||
proof: VerifiedIMIdentityProof,
|
||||
) -> SubmissionActor | SubmissionAuthorizationRejection:
|
||||
if not isinstance(context.grant.subject, ContactApprovalSubject):
|
||||
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
current_contact = cls._validate_current_contact(context, context.grant.subject.contact_id)
|
||||
if isinstance(current_contact, SubmissionAuthorizationRejection):
|
||||
return current_contact
|
||||
if current_contact.account_id is None:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
current_im = context.current_im_binding
|
||||
if current_im is None:
|
||||
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
if (
|
||||
current_im.contact_id != context.grant.subject.contact_id
|
||||
or current_im.account_id != current_contact.account_id
|
||||
or current_im.integration_id != proof.integration_id
|
||||
or current_im.identity_id != proof.identity_id
|
||||
or current_im.binding_id != proof.binding_id
|
||||
or current_im.provider is not proof.provider
|
||||
or current_im.provider_tenant_id != proof.provider_tenant_id
|
||||
or current_im.provider_user_id != proof.provider_user_id
|
||||
):
|
||||
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
return AccountSubmissionActor(current_contact.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _validate_current_contact(
|
||||
context: AuthorizationContext,
|
||||
contact_id: ContactId,
|
||||
) -> CurrentContactAuthorizationFacts | SubmissionAuthorizationRejection:
|
||||
current = context.current_contact
|
||||
if current is None or current.contact_id != contact_id:
|
||||
return SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
if current.account_id is not None and not current.account_active:
|
||||
return SubmissionAuthorizationRejection.ACCOUNT_DISABLED
|
||||
if not current.workspace_available:
|
||||
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
|
||||
return current
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountSubmissionActor",
|
||||
"AuthorizationContext",
|
||||
"AuthorizedSubmission",
|
||||
"CurrentContactAuthorizationFacts",
|
||||
"CurrentEndUserAuthorizationFacts",
|
||||
"CurrentIMAuthorizationFacts",
|
||||
"EmailAddressSubmissionActor",
|
||||
"EndUserSubmissionActor",
|
||||
"SubmissionActor",
|
||||
"SubmissionAuthorizationDecision",
|
||||
"SubmissionAuthorizationRejection",
|
||||
"SubmissionAuthorizer",
|
||||
"VerifiedAccountSessionProof",
|
||||
"VerifiedIMIdentityProof",
|
||||
"VerifiedSubmissionProof",
|
||||
"VerifiedTrustedEndUserProof",
|
||||
]
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Transaction-oriented ports for current authorization and first-success commit.
|
||||
|
||||
The transaction object keeps one coherent authorization context alive through
|
||||
the winning write set. Generic CRUD is intentionally absent; persistence owns
|
||||
the Form lock, rejection audit append, and atomic authorized commit shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
DeliveryEndpointId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import FormRef
|
||||
from .submission_authorization import AuthorizationContext, AuthorizedSubmission
|
||||
from .submission_records import FormAuthorizationAuditEvent, FormSubmission
|
||||
|
||||
|
||||
class RetryableSubmissionPersistenceError(RuntimeError):
|
||||
"""A complete submission transaction must be retried with a fresh snapshot."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionAttemptScope:
|
||||
"""Complete logical owner chain selected before the transaction begins."""
|
||||
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorizedSubmissionCommit:
|
||||
"""Caller-owned identities and structured values for one authorized write set."""
|
||||
|
||||
submission_id: SubmissionId
|
||||
authorization_audit_event_id: AuditEventId
|
||||
authorized: AuthorizedSubmission
|
||||
input_snapshot: FrozenJSONObject
|
||||
canonical_values: FrozenJSONObject
|
||||
|
||||
def to_submission(
|
||||
self,
|
||||
*,
|
||||
form_ref: FormRef,
|
||||
approver_grant_id: ApproverGrantId,
|
||||
endpoint_id: DeliveryEndpointId | None,
|
||||
submitted_at: UtcTimestamp,
|
||||
) -> FormSubmission:
|
||||
"""Build the immutable record value after owner-scope validation."""
|
||||
|
||||
return FormSubmission(
|
||||
id=self.submission_id,
|
||||
form_ref=form_ref,
|
||||
approver_grant_id=approver_grant_id,
|
||||
endpoint_id=endpoint_id,
|
||||
authorization_audit_event_id=self.authorization_audit_event_id,
|
||||
actor=self.authorized.actor,
|
||||
selected_action_id=self.authorized.transition.selected_action_id,
|
||||
input_snapshot=self.input_snapshot,
|
||||
canonical_values=self.canonical_values,
|
||||
submitted_at=submitted_at,
|
||||
created_at=submitted_at,
|
||||
updated_at=submitted_at,
|
||||
)
|
||||
|
||||
|
||||
class SubmissionCommitStatus(StrEnum):
|
||||
"""Stable first-success persistence outcome."""
|
||||
|
||||
COMMITTED = "committed"
|
||||
ALREADY_COMPLETED = "already_completed"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionCommitResult:
|
||||
"""Committed winning submission or stable loser result."""
|
||||
|
||||
status: SubmissionCommitStatus
|
||||
submission: FormSubmission | None
|
||||
|
||||
|
||||
class SubmissionTransaction(Protocol):
|
||||
"""One session-bound authorization and commit transaction."""
|
||||
|
||||
def load_authorization_context(self, *, proof: object) -> AuthorizationContext: ...
|
||||
|
||||
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None: ...
|
||||
|
||||
def commit_authorized_submission_once(self, commit: AuthorizedSubmissionCommit) -> SubmissionCommitResult: ...
|
||||
|
||||
|
||||
class SubmissionRepository(Protocol):
|
||||
"""Factory for one short transaction owning the complete submission use case."""
|
||||
|
||||
def transaction(self, scope: SubmissionAttemptScope) -> AbstractContextManager[SubmissionTransaction]: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthorizedSubmissionCommit",
|
||||
"RetryableSubmissionPersistenceError",
|
||||
"SubmissionAttemptScope",
|
||||
"SubmissionCommitResult",
|
||||
"SubmissionCommitStatus",
|
||||
"SubmissionRepository",
|
||||
"SubmissionTransaction",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Immutable submission and shared authorization-audit persistence values.
|
||||
|
||||
These values preserve business identity and structured snapshots without
|
||||
exposing ORM records. Persistence mappers alone translate them to storage
|
||||
columns and Pydantic JSON values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.entities import HumanInputDeliveryChannel
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
DeliveryEndpointId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .frozen_values import FrozenJSONObject
|
||||
from .grants import FormRef
|
||||
from .submission_authorization import SubmissionActor, VerifiedEmailOTPProof, VerifiedSubmissionProof
|
||||
|
||||
|
||||
class FormAuthorizationAuditEventType(StrEnum):
|
||||
"""Stable append-only event names owned by the shared audit table."""
|
||||
|
||||
OTP_CHALLENGE_ISSUED = "otp_challenge_issued"
|
||||
SUBMISSION_AUTHORIZED = "submission_authorized"
|
||||
SUBMISSION_REJECTED = "submission_rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormAuthorizationAuditEvent:
|
||||
"""Secret-free authorized, rejected, or OTP issuance audit fact."""
|
||||
|
||||
id: AuditEventId
|
||||
event_type: FormAuthorizationAuditEventType
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId | None
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
channel: HumanInputDeliveryChannel | None
|
||||
reason_code: str | None
|
||||
reason_message: str | None
|
||||
authorization_proof: VerifiedSubmissionProof | None
|
||||
payload: FrozenJSONObject | None
|
||||
occurred_at: UtcTimestamp
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED:
|
||||
if self.approver_grant_id is None or self.authorization_proof is None:
|
||||
raise ValueError("authorized audit event requires a grant and verified proof")
|
||||
if self.reason_code is not None:
|
||||
raise ValueError("authorized audit event cannot contain a rejection reason")
|
||||
self.validate_authorization_proof_owner()
|
||||
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_REJECTED and not self.reason_code:
|
||||
raise ValueError("rejected audit event requires a stable reason code")
|
||||
|
||||
def validate_authorization_proof_owner(self) -> None:
|
||||
"""Reject authorized Email evidence captured for another form or grant."""
|
||||
|
||||
proof = self.authorization_proof
|
||||
if not isinstance(proof, VerifiedEmailOTPProof):
|
||||
return
|
||||
if (
|
||||
proof.challenge_ref.form_ref != self.form_ref
|
||||
or proof.challenge_ref.grant_ref.grant_id != self.approver_grant_id
|
||||
):
|
||||
raise ValueError("authorized Email proof owner does not match the audit event")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FormSubmission:
|
||||
"""Immutable winning submission mapped independently from ORM lifetime."""
|
||||
|
||||
id: SubmissionId
|
||||
form_ref: FormRef
|
||||
approver_grant_id: ApproverGrantId
|
||||
endpoint_id: DeliveryEndpointId | None
|
||||
authorization_audit_event_id: AuditEventId
|
||||
actor: SubmissionActor
|
||||
selected_action_id: str
|
||||
input_snapshot: FrozenJSONObject
|
||||
canonical_values: FrozenJSONObject
|
||||
submitted_at: UtcTimestamp
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.selected_action_id.strip():
|
||||
raise ValueError("submission selected action must not be blank")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FormAuthorizationAuditEvent",
|
||||
"FormAuthorizationAuditEventType",
|
||||
"FormSubmission",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Infrastructure-free Contact Directory domain boundary.
|
||||
|
||||
Transport and persistence layers may depend on this package. This package must
|
||||
not import controllers, Flask, SQLAlchemy, sessions, or ORM records.
|
||||
"""
|
||||
|
||||
from .entities import (
|
||||
Contact,
|
||||
ContactIdentitySource,
|
||||
ContactOwner,
|
||||
ContactSnapshot,
|
||||
ExternalContactOwner,
|
||||
OrganizationAccountOwner,
|
||||
PlatformWorkspaceEntry,
|
||||
WorkspaceMemberOwner,
|
||||
)
|
||||
from .errors import ContactDirectoryError, ContactRejection, ContactRejectionCode
|
||||
from .policy import ContactDirectoryPolicy, ContactDirectorySnapshot, ContactResolution
|
||||
from .ports import ContactDirectoryRepository
|
||||
|
||||
__all__ = [
|
||||
"Contact",
|
||||
"ContactDirectoryError",
|
||||
"ContactDirectoryPolicy",
|
||||
"ContactDirectoryRepository",
|
||||
"ContactDirectorySnapshot",
|
||||
"ContactIdentitySource",
|
||||
"ContactOwner",
|
||||
"ContactRejection",
|
||||
"ContactRejectionCode",
|
||||
"ContactResolution",
|
||||
"ContactSnapshot",
|
||||
"ExternalContactOwner",
|
||||
"OrganizationAccountOwner",
|
||||
"PlatformWorkspaceEntry",
|
||||
"WorkspaceMemberOwner",
|
||||
]
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Canonical Contact identity independent of workspace-relative resolution.
|
||||
|
||||
Contacts own identity invariants and current profile facts. Membership,
|
||||
allow-list state, database I/O, and transport serialization remain outside the
|
||||
entity so its immutable lifecycle source cannot be confused with a query result.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
NormalizedEmail,
|
||||
PlatformEntryId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
from .errors import ContactRejectionCode, reject
|
||||
|
||||
|
||||
class ContactIdentitySource(StrEnum):
|
||||
"""Immutable lifecycle source of a canonical Contact."""
|
||||
|
||||
ORGANIZATION_ACCOUNT = "organization_account"
|
||||
WORKSPACE_MEMBER = "workspace_member"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationAccountOwner:
|
||||
"""Deployment-wide EE owner reference backed by one Account."""
|
||||
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceMemberOwner:
|
||||
"""Workspace owner reference backed by one current or historical Account."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
account_id: AccountId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExternalContactOwner:
|
||||
"""Workspace owner reference for an address managed by administrators."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
|
||||
|
||||
type ContactOwner = OrganizationAccountOwner | WorkspaceMemberOwner | ExternalContactOwner
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Contact:
|
||||
"""Canonical identity whose source and owner remain immutable.
|
||||
|
||||
Use the named factories for normal construction. ``create`` exists for
|
||||
persistence mapping and validates the same source/owner invariant.
|
||||
"""
|
||||
|
||||
id: ContactId
|
||||
identity_source: ContactIdentitySource
|
||||
owner: ContactOwner
|
||||
name: str
|
||||
normalized_name: str
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
avatar_file_id: str | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
expected_owner_type = {
|
||||
ContactIdentitySource.ORGANIZATION_ACCOUNT: OrganizationAccountOwner,
|
||||
ContactIdentitySource.WORKSPACE_MEMBER: WorkspaceMemberOwner,
|
||||
ContactIdentitySource.EXTERNAL: ExternalContactOwner,
|
||||
}[self.identity_source]
|
||||
if not isinstance(self.owner, expected_owner_type):
|
||||
raise reject(ContactRejectionCode.INVALID_OWNER)
|
||||
if not self.name.strip():
|
||||
raise reject(ContactRejectionCode.INVALID_NAME)
|
||||
if self.identity_source is ContactIdentitySource.EXTERNAL and self.normalized_email is None:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
if self.email is None and self.normalized_email is not None:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
if self.email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(self.email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
if self.normalized_email != normalized_email:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL)
|
||||
object.__setattr__(self, "email", self.email.strip())
|
||||
normalized_name = self.name.strip().casefold()
|
||||
if self.normalized_name != normalized_name:
|
||||
object.__setattr__(self, "normalized_name", normalized_name)
|
||||
object.__setattr__(self, "name", self.name.strip())
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
identity_source: ContactIdentitySource,
|
||||
owner: ContactOwner,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> Contact:
|
||||
normalized_email: NormalizedEmail | None = None
|
||||
if email is not None:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
return cls(
|
||||
id=contact_id,
|
||||
identity_source=identity_source,
|
||||
owner=owner,
|
||||
name=name,
|
||||
normalized_name=name.strip().casefold(),
|
||||
email=email,
|
||||
normalized_email=normalized_email,
|
||||
avatar_file_id=avatar_file_id,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def organization_account(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
account_id: AccountId,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
owner=OrganizationAccountOwner(account_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def workspace_member(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
workspace_id: WorkspaceId,
|
||||
account_id: AccountId,
|
||||
name: str,
|
||||
email: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.WORKSPACE_MEMBER,
|
||||
owner=WorkspaceMemberOwner(workspace_id, account_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def external(
|
||||
cls,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
workspace_id: WorkspaceId,
|
||||
name: str,
|
||||
email: str,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
) -> Contact:
|
||||
return cls.create(
|
||||
contact_id=contact_id,
|
||||
identity_source=ContactIdentitySource.EXTERNAL,
|
||||
owner=ExternalContactOwner(workspace_id),
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
avatar_file_id=avatar_file_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def account_id(self) -> AccountId | None:
|
||||
if isinstance(self.owner, OrganizationAccountOwner | WorkspaceMemberOwner):
|
||||
return self.owner.account_id
|
||||
return None
|
||||
|
||||
@property
|
||||
def directory_scope(self) -> DirectoryScope:
|
||||
if isinstance(self.owner, OrganizationAccountOwner):
|
||||
return DeploymentScope()
|
||||
return WorkspaceScope(self.owner.workspace_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactSnapshot:
|
||||
"""Immutable Contact plus current Account availability for one operation."""
|
||||
|
||||
contact: Contact
|
||||
account_available: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PlatformWorkspaceEntry:
|
||||
"""One workspace allow-list fact for an Organization Account Contact."""
|
||||
|
||||
id: PlatformEntryId
|
||||
workspace_id: WorkspaceId
|
||||
contact_id: ContactId
|
||||
added_by_account_id: AccountId
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Transport-neutral Contact Directory rejection contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class ContactRejectionCode(StrEnum):
|
||||
"""Stable machine-readable reasons returned by Contact Directory operations."""
|
||||
|
||||
INVALID_OWNER = "invalid_owner"
|
||||
INVALID_EMAIL = "invalid_email"
|
||||
INVALID_NAME = "invalid_name"
|
||||
CONFLICTING_IDENTITY = "conflicting_identity"
|
||||
CROSS_ORGANIZATION = "cross_organization"
|
||||
ACCOUNT_UNAVAILABLE = "account_unavailable"
|
||||
CONTACT_NOT_FOUND = "contact_not_found"
|
||||
SETUP_ROW_MISSING = "setup_row_missing"
|
||||
PERSISTENCE_FAILURE = "persistence_failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactRejection:
|
||||
"""Serializable domain rejection without HTTP or RPC semantics."""
|
||||
|
||||
reason: ContactRejectionCode
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"reason": self.reason.value}
|
||||
|
||||
|
||||
class ContactDirectoryError(Exception):
|
||||
"""Exception carrier for one transport-neutral Contact rejection."""
|
||||
|
||||
rejection: ContactRejection
|
||||
|
||||
def __init__(self, rejection: ContactRejection) -> None:
|
||||
self.rejection = rejection
|
||||
super().__init__(rejection.reason.value)
|
||||
|
||||
@property
|
||||
def code(self) -> ContactRejectionCode:
|
||||
return self.rejection.reason
|
||||
|
||||
|
||||
def reject(reason: ContactRejectionCode) -> ContactDirectoryError:
|
||||
"""Build a domain exception while keeping reason construction consistent."""
|
||||
|
||||
return ContactDirectoryError(ContactRejection(reason))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pure Contact Directory resolution and lifecycle policies.
|
||||
|
||||
The immutable snapshot supplies all operation-scoped facts. Policies never load
|
||||
membership, Account, allow-list, or Contact records themselves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.shared import AccountId, ContactId, NormalizedEmail, UtcTimestamp, WorkspaceId
|
||||
|
||||
from .entities import Contact, ExternalContactOwner, OrganizationAccountOwner, WorkspaceMemberOwner
|
||||
from .errors import ContactRejectionCode, reject
|
||||
|
||||
|
||||
class ContactResolution(StrEnum):
|
||||
"""Workspace-relative availability of one canonical Contact."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PLATFORM = "platform"
|
||||
EXTERNAL = "external"
|
||||
ABSENT = "absent"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContactDirectorySnapshot:
|
||||
"""Coherent, request-scoped Contact facts for one workspace.
|
||||
|
||||
The snapshot is deliberately not a cache. Authorization callers that need
|
||||
current facts must load a new snapshot in their own operation.
|
||||
"""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
contacts: tuple[Contact, ...] = ()
|
||||
member_account_ids: frozenset[AccountId] = frozenset()
|
||||
platform_contact_ids: frozenset[ContactId] = frozenset()
|
||||
unavailable_account_ids: frozenset[AccountId] = frozenset()
|
||||
|
||||
def find(self, contact_id: ContactId) -> Contact | None:
|
||||
return next((contact for contact in self.contacts if contact.id == contact_id), None)
|
||||
|
||||
|
||||
class ContactDirectoryPolicy:
|
||||
"""Stateless policy for workspace resolution and External lifecycle rules."""
|
||||
|
||||
@staticmethod
|
||||
def resolve_for_workspace(snapshot: ContactDirectorySnapshot, contact_id: ContactId) -> ContactResolution:
|
||||
contact = snapshot.find(contact_id)
|
||||
if contact is None:
|
||||
return ContactResolution.ABSENT
|
||||
|
||||
owner = contact.owner
|
||||
if isinstance(owner, ExternalContactOwner):
|
||||
if owner.workspace_id != snapshot.workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
return ContactResolution.EXTERNAL
|
||||
if isinstance(owner, WorkspaceMemberOwner) and owner.workspace_id != snapshot.workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
|
||||
account_id = contact.account_id
|
||||
if account_id is None or account_id in snapshot.unavailable_account_ids:
|
||||
return ContactResolution.ABSENT
|
||||
if account_id in snapshot.member_account_ids:
|
||||
return ContactResolution.WORKSPACE
|
||||
if isinstance(owner, OrganizationAccountOwner) and contact.id in snapshot.platform_contact_ids:
|
||||
return ContactResolution.PLATFORM
|
||||
return ContactResolution.ABSENT
|
||||
|
||||
@staticmethod
|
||||
def admit_external(
|
||||
snapshot: ContactDirectorySnapshot,
|
||||
*,
|
||||
contact_id: ContactId,
|
||||
name: str,
|
||||
email: str,
|
||||
now: UtcTimestamp,
|
||||
avatar_file_id: str | None = None,
|
||||
) -> Contact:
|
||||
try:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
except ValueError as error:
|
||||
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
|
||||
if any(contact.normalized_email == normalized_email for contact in snapshot.contacts):
|
||||
raise reject(ContactRejectionCode.CONFLICTING_IDENTITY)
|
||||
return Contact.external(
|
||||
contact_id=contact_id,
|
||||
workspace_id=snapshot.workspace_id,
|
||||
name=name,
|
||||
email=email,
|
||||
now=now,
|
||||
avatar_file_id=avatar_file_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ensure_external_deletable(contact: Contact, workspace_id: WorkspaceId) -> None:
|
||||
if not isinstance(contact.owner, ExternalContactOwner):
|
||||
raise reject(ContactRejectionCode.INVALID_OWNER)
|
||||
if contact.owner.workspace_id != workspace_id:
|
||||
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Aggregate-oriented persistence ports for Contact Directory operations.
|
||||
|
||||
Implementations own transactions, owner predicates, locking, mapping, and
|
||||
rollback. Callers receive domain values and never persistence records.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.shared import AccountId, ContactId, WorkspaceId
|
||||
|
||||
from .entities import Contact
|
||||
from .policy import ContactDirectorySnapshot
|
||||
|
||||
|
||||
class ContactDirectoryRepository(Protocol):
|
||||
"""Persistence contract centered on coherent directory invariants."""
|
||||
|
||||
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
|
||||
"""Load one immutable workspace-scoped directory view."""
|
||||
...
|
||||
|
||||
def save_organization_contact(self, contact: Contact) -> Contact:
|
||||
"""Create or update one deployment-owned Organization Contact with a serialized Email claim."""
|
||||
...
|
||||
|
||||
def save_workspace_member_contact(self, contact: Contact) -> Contact:
|
||||
"""Create or update one Contact backed by current workspace membership."""
|
||||
...
|
||||
|
||||
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
|
||||
"""Atomically admit one External Contact against tenant and configured Organization identities."""
|
||||
...
|
||||
|
||||
def set_platform_availability(
|
||||
self,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
*,
|
||||
added_by_account_id: AccountId,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
"""Atomically add or remove one EE Platform allow-list fact."""
|
||||
...
|
||||
|
||||
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
|
||||
"""Delete an External Contact without retaining an identity tombstone."""
|
||||
...
|
||||
@@ -0,0 +1,197 @@
|
||||
from enum import StrEnum
|
||||
from typing import NewType
|
||||
|
||||
from core.human_input_v2.shared import AccountId, NormalizedEmail, UtcTimestamp, WorkspaceId
|
||||
|
||||
|
||||
class HumanInputContactType(StrEnum):
|
||||
"""Concrete contact classification resolved in one workspace."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
PLATFORM = "platform"
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class HumanInputApproverGrantSubjectType(StrEnum):
|
||||
"""Business subject receiving approval authority for one Human Input form."""
|
||||
|
||||
CONTACT = "contact"
|
||||
END_USER = "end_user"
|
||||
EMAIL_ADDRESS = "email_address"
|
||||
|
||||
|
||||
class HumanInputSubmissionActorType(StrEnum):
|
||||
"""Business identity that completed one Human Input form submission."""
|
||||
|
||||
ACCOUNT = "account"
|
||||
END_USER = "end_user"
|
||||
EMAIL_ADDRESS = "email_address"
|
||||
|
||||
|
||||
class HumanInputV2FormKind(StrEnum):
|
||||
"""Persistence kind for an independently stored Human Input v2 form."""
|
||||
|
||||
RUNTIME = "runtime"
|
||||
DELIVERY_TEST = "delivery_test"
|
||||
|
||||
|
||||
class HumanInputV2FormStatus(StrEnum):
|
||||
"""Lifecycle state of an independently stored Human Input v2 form."""
|
||||
|
||||
WAITING = "waiting"
|
||||
EXPIRED = "expired"
|
||||
SUBMITTED = "submitted"
|
||||
TIMEOUT = "timeout"
|
||||
|
||||
|
||||
class HumanInputAuthorizationProofType(StrEnum):
|
||||
"""Verified evidence type retained for a Human Input authorization audit event."""
|
||||
|
||||
ACCOUNT_SESSION = "account_session"
|
||||
EMAIL_OTP = "email_otp"
|
||||
IM_IDENTITY = "im_identity"
|
||||
TRUSTED_END_USER = "trusted_end_user"
|
||||
|
||||
|
||||
class HumanInputDeliveryChannel(StrEnum):
|
||||
"""Notification or interaction channel frozen for one form endpoint."""
|
||||
|
||||
EMAIL = "email"
|
||||
IM = "im"
|
||||
WEB = "web"
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class HumanInputDeliveryAttemptStatus(StrEnum):
|
||||
"""Delivery lifecycle kept separate from the form state machine."""
|
||||
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class HumanInputOTPChallengeStatus(StrEnum):
|
||||
"""Current usability of an email proof challenge."""
|
||||
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
INVALIDATED = "invalidated"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class IMProvider(StrEnum):
|
||||
"""IM provider supported by Human Input contact and delivery flows."""
|
||||
|
||||
FEISHU = "feishu"
|
||||
SLACK = "slack"
|
||||
DING_TALK = "ding_talk"
|
||||
MS_TEAMS = "ms_teams"
|
||||
WE_COM = "we_com"
|
||||
LARK = "lark"
|
||||
|
||||
|
||||
class IMBindingScope(StrEnum):
|
||||
"""Resolution scope of a contact-to-IM-identity binding."""
|
||||
|
||||
WORKSPACE = "workspace"
|
||||
ORGANIZATION = "organization"
|
||||
|
||||
|
||||
class IMIntegrationStatus(StrEnum):
|
||||
"""Connectivity state of an organization-level IM integration."""
|
||||
|
||||
NOT_CONFIGURED = "not_configured"
|
||||
CONFIGURED = "configured"
|
||||
CONNECTED = "connected"
|
||||
PERMISSION_ISSUE = "permission_issue"
|
||||
CALLBACK_ERROR = "callback_error"
|
||||
CONNECTION_ERROR = "connection_error"
|
||||
|
||||
|
||||
class IMIdentityBindingStatus(StrEnum):
|
||||
"""Whether a synchronized IM identity is currently bound."""
|
||||
|
||||
UNBOUND = "unbound"
|
||||
BOUND = "bound"
|
||||
|
||||
|
||||
class IMSyncRunStatus(StrEnum):
|
||||
"""Lifecycle state of a manual IM directory synchronization."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class IMSyncResultType(StrEnum):
|
||||
"""Stable reconciliation bucket for one synchronized directory entry."""
|
||||
|
||||
ADDED = "added"
|
||||
NOT_MATCHED = "not_matched"
|
||||
FAILED = "failed"
|
||||
REMOVED = "removed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class IMSyncRemovalReason(StrEnum):
|
||||
"""Stable reason for removing or replacing a current IM binding."""
|
||||
|
||||
NOT_PRESENT_IN_DIRECTORY = "not_present_in_directory"
|
||||
BINDING_INVALIDATED = "binding_invalidated"
|
||||
BINDING_REPLACED = "binding_replaced"
|
||||
|
||||
|
||||
class EmailProviderType(StrEnum):
|
||||
"""Email provider supported by organization-level Human Input delivery."""
|
||||
|
||||
RESEND = "resend"
|
||||
|
||||
|
||||
# Identifiers for organization candidates and contacts.
|
||||
OrganizationCandidateId = NewType("OrganizationCandidateId", str)
|
||||
|
||||
# Legacy transport identifier. Contact Directory code uses the richer value
|
||||
# object from ``core.human_input_v2.shared`` at its domain boundary.
|
||||
ContactId = NewType("ContactId", str)
|
||||
|
||||
# Identifiers for synced IM identiies. This is not the same as user_id or account_id
|
||||
# on the IM provier side. It is the identifier for the synced IM user record in Dify.
|
||||
IMIdentityId = NewType("IMIdentityId", str)
|
||||
|
||||
# Identifiers for a full IM user synchorization.
|
||||
IMSyncRunId = NewType("IMSyncRunId", str)
|
||||
|
||||
# Identifier for an IM binding, an association between an IM identity and a Dify contact.
|
||||
IMBindingId = NewType("IMBindingId", str)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"ContactId",
|
||||
"EmailProviderType",
|
||||
"HumanInputApproverGrantSubjectType",
|
||||
"HumanInputAuthorizationProofType",
|
||||
"HumanInputContactType",
|
||||
"HumanInputDeliveryAttemptStatus",
|
||||
"HumanInputDeliveryChannel",
|
||||
"HumanInputOTPChallengeStatus",
|
||||
"HumanInputSubmissionActorType",
|
||||
"HumanInputV2FormKind",
|
||||
"HumanInputV2FormStatus",
|
||||
"IMBindingId",
|
||||
"IMBindingScope",
|
||||
"IMIdentityBindingStatus",
|
||||
"IMIdentityId",
|
||||
"IMIntegrationStatus",
|
||||
"IMProvider",
|
||||
"IMSyncRemovalReason",
|
||||
"IMSyncResultType",
|
||||
"IMSyncRunId",
|
||||
"IMSyncRunStatus",
|
||||
"NormalizedEmail",
|
||||
"OrganizationCandidateId",
|
||||
"UtcTimestamp",
|
||||
"WorkspaceId",
|
||||
]
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Infrastructure-free IM configuration, synchronization, and binding boundary.
|
||||
|
||||
This package depends on canonical Contact Directory facts and shared primitive
|
||||
values. Provider clients, controllers, SQLAlchemy sessions, ORM records, and
|
||||
provider transport payload types belong outside this boundary. The public API
|
||||
hides configuration CAS, sync matching, and effective-binding priority behind
|
||||
domain decisions and transaction-oriented ports.
|
||||
"""
|
||||
|
||||
from .binding_resolution import (
|
||||
BindingResolutionKind,
|
||||
BindingResolutionResult,
|
||||
EffectiveBindingResolver,
|
||||
EffectiveIMBindingSnapshot,
|
||||
)
|
||||
from .integration import (
|
||||
ConfigurationTransition,
|
||||
ConfigurationTransitionKind,
|
||||
CurrentStateInvalidation,
|
||||
EncryptedCredentials,
|
||||
IMIntegration,
|
||||
IntegrationDeletion,
|
||||
IntegrationRevisionToken,
|
||||
ProviderTenantIdentity,
|
||||
StaleRevision,
|
||||
)
|
||||
from .ports import (
|
||||
ActiveRunDecision,
|
||||
ActiveRunDecisionKind,
|
||||
ApplyReconciliationResult,
|
||||
ApplyReconciliationStatus,
|
||||
IMControlPlaneRepository,
|
||||
)
|
||||
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
|
||||
from .state import IMIntegrationState
|
||||
from .sync_reconciliation import (
|
||||
IMSyncRun,
|
||||
MatchKind,
|
||||
ProviderDirectoryEntry,
|
||||
ReconciliationAction,
|
||||
ReconciliationPlan,
|
||||
ReconciliationSnapshot,
|
||||
SyncContactSnapshot,
|
||||
SyncIdentitySnapshot,
|
||||
SyncReconciler,
|
||||
SyncResultFact,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"BindingResolutionKind",
|
||||
"BindingResolutionResult",
|
||||
"ConfigurationTransition",
|
||||
"ConfigurationTransitionKind",
|
||||
"CurrentStateInvalidation",
|
||||
"EffectiveBindingResolver",
|
||||
"EffectiveIMBindingSnapshot",
|
||||
"EncryptedCredentials",
|
||||
"IMBinding",
|
||||
"IMControlPlaneRepository",
|
||||
"IMIdentity",
|
||||
"IMIntegration",
|
||||
"IMIntegrationState",
|
||||
"IMSyncRun",
|
||||
"IntegrationDeletion",
|
||||
"IntegrationRevisionToken",
|
||||
"MatchKind",
|
||||
"OpaqueProviderPayload",
|
||||
"ProviderDirectoryEntry",
|
||||
"ProviderTenantIdentity",
|
||||
"ReconciliationAction",
|
||||
"ReconciliationPlan",
|
||||
"ReconciliationSnapshot",
|
||||
"StaleRevision",
|
||||
"SyncContactSnapshot",
|
||||
"SyncIdentitySnapshot",
|
||||
"SyncReconciler",
|
||||
"SyncResultFact",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Effective binding priority and credential-free consumer snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactSnapshot
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .integration import IntegrationRevisionToken, ProviderTenantIdentity
|
||||
from .records import IMBinding, IMIdentity
|
||||
|
||||
|
||||
class BindingResolutionKind(StrEnum):
|
||||
"""Stable priority result returned to control-plane consumers."""
|
||||
|
||||
WORKSPACE_OVERRIDE = "workspace_override"
|
||||
ORGANIZATION_BINDING = "organization_binding"
|
||||
EMAIL_FALLBACK = "email_fallback"
|
||||
NOT_AVAILABLE = "not_available"
|
||||
INVALID_BINDING = "invalid_binding"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveIMBindingSnapshot:
|
||||
"""Consumer-safe effective channel facts without credentials or raw payloads."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
integration_config_version: int
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
contact_id: ContactId
|
||||
account_id: AccountId | None
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId | None
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BindingResolutionResult:
|
||||
"""Effective binding or stable rejection without leaking invalid records."""
|
||||
|
||||
kind: BindingResolutionKind
|
||||
binding: EffectiveIMBindingSnapshot | None
|
||||
|
||||
|
||||
class EffectiveBindingResolver:
|
||||
"""Resolve workspace override, organization binding, then Email fallback."""
|
||||
|
||||
@staticmethod
|
||||
def resolve(
|
||||
*,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
workspace_id: WorkspaceId,
|
||||
contact: ContactSnapshot,
|
||||
identities: tuple[IMIdentity, ...],
|
||||
bindings: tuple[IMBinding, ...],
|
||||
) -> BindingResolutionResult:
|
||||
identities_by_id = {identity.id: identity for identity in identities}
|
||||
candidates = [binding for binding in bindings if binding.contact_id == contact.contact.id]
|
||||
workspace_binding = next(
|
||||
(
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.scope is IMBindingScope.WORKSPACE and binding.scope_id == str(workspace_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
organization_binding = next(
|
||||
(
|
||||
binding
|
||||
for binding in candidates
|
||||
if binding.scope is IMBindingScope.ORGANIZATION
|
||||
and binding.scope_id == str(integration_revision.integration_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
selected = workspace_binding or organization_binding
|
||||
if selected is not None:
|
||||
identity = identities_by_id.get(selected.identity_id)
|
||||
if not EffectiveBindingResolver._matches_integration(
|
||||
selected,
|
||||
identity,
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
):
|
||||
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
|
||||
assert identity is not None
|
||||
kind = (
|
||||
BindingResolutionKind.WORKSPACE_OVERRIDE
|
||||
if selected is workspace_binding
|
||||
else BindingResolutionKind.ORGANIZATION_BINDING
|
||||
)
|
||||
return BindingResolutionResult(
|
||||
kind,
|
||||
EffectiveBindingResolver._snapshot(
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
contact,
|
||||
identity,
|
||||
selected.id,
|
||||
),
|
||||
)
|
||||
|
||||
normalized_email = contact.contact.normalized_email
|
||||
if normalized_email is not None:
|
||||
identity = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in identities
|
||||
if candidate.integration_id == integration_revision.integration_id
|
||||
and candidate.provider is provider_tenant.provider
|
||||
and candidate.normalized_email == normalized_email
|
||||
),
|
||||
None,
|
||||
)
|
||||
if identity is not None:
|
||||
return BindingResolutionResult(
|
||||
BindingResolutionKind.EMAIL_FALLBACK,
|
||||
EffectiveBindingResolver._snapshot(
|
||||
integration_revision,
|
||||
provider_tenant,
|
||||
contact,
|
||||
identity,
|
||||
None,
|
||||
),
|
||||
)
|
||||
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
|
||||
|
||||
@staticmethod
|
||||
def _matches_integration(
|
||||
binding: IMBinding,
|
||||
identity: IMIdentity | None,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
) -> bool:
|
||||
return (
|
||||
identity is not None
|
||||
and binding.integration_id == integration_revision.integration_id
|
||||
and identity.integration_id == integration_revision.integration_id
|
||||
and binding.provider is provider_tenant.provider
|
||||
and identity.provider is provider_tenant.provider
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot(
|
||||
revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
contact: ContactSnapshot,
|
||||
identity: IMIdentity,
|
||||
binding_id: IMBindingId | None,
|
||||
) -> EffectiveIMBindingSnapshot:
|
||||
return EffectiveIMBindingSnapshot(
|
||||
integration_id=revision.integration_id,
|
||||
integration_config_version=revision.config_version,
|
||||
provider=provider_tenant.provider,
|
||||
provider_tenant_id=provider_tenant.provider_tenant_id,
|
||||
contact_id=contact.contact.id,
|
||||
account_id=contact.contact.account_id,
|
||||
identity_id=identity.id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=identity.provider_user_id,
|
||||
display_name=identity.display_name,
|
||||
email=identity.email,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
"""IM Integration aggregate and complete compare-and-swap revision values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.entities import IMIntegrationStatus, IMProvider
|
||||
from core.human_input_v2.shared import AccountId, IntegrationId, UtcTimestamp, WorkspaceId
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncryptedCredentials:
|
||||
"""Immutable opaque encrypted configuration passed through the domain boundary."""
|
||||
|
||||
_serialized: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, JsonValue]) -> EncryptedCredentials:
|
||||
if not values:
|
||||
raise ValueError("encrypted credentials must not be empty")
|
||||
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
def to_mapping(self) -> dict[str, JsonValue]:
|
||||
value = json.loads(self._serialized)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("encrypted credentials must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderTenantIdentity:
|
||||
"""Provider plus its confirmed organization or workspace identity."""
|
||||
|
||||
provider: IMProvider
|
||||
provider_tenant_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_tenant_id.strip():
|
||||
raise ValueError("provider tenant id must not be blank")
|
||||
object.__setattr__(self, "provider_tenant_id", self.provider_tenant_id.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrationRevisionToken:
|
||||
"""Complete CAS token that prevents identity-replacement ABA."""
|
||||
|
||||
integration_id: IntegrationId
|
||||
config_version: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.config_version < 1:
|
||||
raise ValueError("config version must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StaleRevision:
|
||||
"""Stable rejection for a token that no longer names current configuration."""
|
||||
|
||||
expected: IntegrationRevisionToken
|
||||
actual: IntegrationRevisionToken | None
|
||||
|
||||
|
||||
class ConfigurationTransitionKind(StrEnum):
|
||||
"""Current-state effect selected by one confirmed configuration write."""
|
||||
|
||||
CREDENTIAL_ROTATION = "credential_rotation"
|
||||
PROVIDER_REPLACEMENT = "provider_replacement"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CurrentStateInvalidation:
|
||||
"""Current identity and binding cleanup owned by the configuration transaction."""
|
||||
|
||||
invalidate_identities: bool
|
||||
invalidate_bindings: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigurationTransition:
|
||||
"""Atomic configuration write plus its current-state cleanup decision."""
|
||||
|
||||
expected_revision: IntegrationRevisionToken
|
||||
kind: ConfigurationTransitionKind
|
||||
integration: IMIntegration
|
||||
invalidation: CurrentStateInvalidation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegrationDeletion:
|
||||
"""CAS-authorized deletion and current-state invalidation decision."""
|
||||
|
||||
expected_revision: IntegrationRevisionToken
|
||||
invalidation: CurrentStateInvalidation = CurrentStateInvalidation(True, True)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIntegration:
|
||||
"""Organization IM configuration aggregate.
|
||||
|
||||
Provider reads and credential encryption happen before construction.
|
||||
Configuration transitions return decisions; persistence adapters alone make
|
||||
them atomic and decide whether the expected revision is still current.
|
||||
Connectivity diagnostics are non-configuration state and retain the token.
|
||||
"""
|
||||
|
||||
id: IntegrationId
|
||||
workspace_id: WorkspaceId | None
|
||||
provider_tenant: ProviderTenantIdentity
|
||||
encrypted_credentials: EncryptedCredentials
|
||||
configured_by_account_id: AccountId | None
|
||||
callback_url: str | None
|
||||
config_version: int
|
||||
status: IMIntegrationStatus
|
||||
safe_status_reason: str | None
|
||||
last_checked_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.config_version < 1:
|
||||
raise ValueError("config version must be positive")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
integration_id: IntegrationId,
|
||||
workspace_id: WorkspaceId | None,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
encrypted_credentials: EncryptedCredentials,
|
||||
configured_by_account_id: AccountId | None,
|
||||
callback_url: str | None,
|
||||
now: UtcTimestamp,
|
||||
) -> IMIntegration:
|
||||
return cls(
|
||||
id=integration_id,
|
||||
workspace_id=workspace_id,
|
||||
provider_tenant=provider_tenant,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
config_version=1,
|
||||
status=IMIntegrationStatus.CONFIGURED,
|
||||
safe_status_reason=None,
|
||||
last_checked_at=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def revision(self) -> IntegrationRevisionToken:
|
||||
return IntegrationRevisionToken(self.id, self.config_version)
|
||||
|
||||
def reconfigure(
|
||||
self,
|
||||
*,
|
||||
expected_revision: IntegrationRevisionToken,
|
||||
provider_tenant: ProviderTenantIdentity,
|
||||
encrypted_credentials: EncryptedCredentials,
|
||||
configured_by_account_id: AccountId | None,
|
||||
callback_url: str | None,
|
||||
now: UtcTimestamp,
|
||||
replacement_integration_id: IntegrationId | None = None,
|
||||
) -> ConfigurationTransition | StaleRevision:
|
||||
"""Plan a confirmed rotation or replacement without performing I/O."""
|
||||
|
||||
if expected_revision != self.revision:
|
||||
return StaleRevision(expected_revision, self.revision)
|
||||
|
||||
if provider_tenant == self.provider_tenant:
|
||||
if replacement_integration_id not in (None, self.id):
|
||||
raise ValueError("credential rotation must preserve integration identity")
|
||||
updated = replace(
|
||||
self,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
config_version=self.config_version + 1,
|
||||
status=IMIntegrationStatus.CONFIGURED,
|
||||
safe_status_reason=None,
|
||||
last_checked_at=None,
|
||||
updated_at=now,
|
||||
)
|
||||
return ConfigurationTransition(
|
||||
expected_revision=expected_revision,
|
||||
kind=ConfigurationTransitionKind.CREDENTIAL_ROTATION,
|
||||
integration=updated,
|
||||
invalidation=CurrentStateInvalidation(False, False),
|
||||
)
|
||||
|
||||
if replacement_integration_id is None or replacement_integration_id == self.id:
|
||||
raise ValueError("provider replacement requires a new integration identity")
|
||||
replacement = IMIntegration.create(
|
||||
integration_id=replacement_integration_id,
|
||||
workspace_id=self.workspace_id,
|
||||
provider_tenant=provider_tenant,
|
||||
encrypted_credentials=encrypted_credentials,
|
||||
configured_by_account_id=configured_by_account_id,
|
||||
callback_url=callback_url,
|
||||
now=now,
|
||||
)
|
||||
return ConfigurationTransition(
|
||||
expected_revision=expected_revision,
|
||||
kind=ConfigurationTransitionKind.PROVIDER_REPLACEMENT,
|
||||
integration=replacement,
|
||||
invalidation=CurrentStateInvalidation(True, True),
|
||||
)
|
||||
|
||||
def plan_deletion(self, expected_revision: IntegrationRevisionToken) -> IntegrationDeletion | StaleRevision:
|
||||
"""Return deletion cleanup only when the complete token is current."""
|
||||
|
||||
if expected_revision != self.revision:
|
||||
return StaleRevision(expected_revision, self.revision)
|
||||
return IntegrationDeletion(expected_revision)
|
||||
|
||||
def record_diagnostics(
|
||||
self,
|
||||
*,
|
||||
status: IMIntegrationStatus,
|
||||
safe_status_reason: str | None,
|
||||
checked_at: UtcTimestamp,
|
||||
) -> IMIntegration:
|
||||
"""Update connection diagnostics without advancing configuration."""
|
||||
|
||||
return replace(
|
||||
self,
|
||||
status=status,
|
||||
safe_status_reason=safe_status_reason,
|
||||
last_checked_at=checked_at,
|
||||
updated_at=checked_at,
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Transaction-oriented persistence ports for IM Control Plane invariants.
|
||||
|
||||
Implementations own CAS predicates, Integration row locks, eager loading,
|
||||
revision-guarded apply, rollback, and append-only result persistence. Generic
|
||||
table CRUD is intentionally absent.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
from .binding_resolution import BindingResolutionResult
|
||||
from .integration import (
|
||||
ConfigurationTransition,
|
||||
IMIntegration,
|
||||
IntegrationDeletion,
|
||||
IntegrationRevisionToken,
|
||||
StaleRevision,
|
||||
)
|
||||
from .sync_reconciliation import IMSyncRun, ReconciliationPlan, ReconciliationSnapshot, SyncResultFact
|
||||
|
||||
|
||||
class ActiveRunDecisionKind(StrEnum):
|
||||
"""Outcome of Integration-locked sync run creation."""
|
||||
|
||||
CREATED = "created"
|
||||
EXISTING_ACTIVE = "existing_active"
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActiveRunDecision:
|
||||
"""New or existing active run, or a stable stale-revision rejection."""
|
||||
|
||||
kind: ActiveRunDecisionKind
|
||||
run: IMSyncRun | None
|
||||
stale_revision: StaleRevision | None = None
|
||||
|
||||
|
||||
class ApplyReconciliationStatus(StrEnum):
|
||||
"""Stable outcome of one idempotent revision-guarded apply."""
|
||||
|
||||
APPLIED = "applied"
|
||||
ALREADY_APPLIED = "already_applied"
|
||||
STALE_REVISION = "stale_revision"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyReconciliationResult:
|
||||
"""Run and append-only facts returned after reconciliation apply."""
|
||||
|
||||
status: ApplyReconciliationStatus
|
||||
run: IMSyncRun
|
||||
results: tuple[SyncResultFact, ...]
|
||||
|
||||
|
||||
class IMControlPlaneRepository(Protocol):
|
||||
"""Atomic persistence capabilities required by the IM domain."""
|
||||
|
||||
def create_integration(self, integration: IMIntegration) -> IMIntegration:
|
||||
"""Create the first integration configuration for its owner scope."""
|
||||
...
|
||||
|
||||
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
|
||||
"""Atomically apply rotation or replacement and its invalidation plan."""
|
||||
...
|
||||
|
||||
def compare_and_swap_delete(self, deletion: IntegrationDeletion) -> None | StaleRevision:
|
||||
"""Delete current configuration and current children under complete CAS."""
|
||||
...
|
||||
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
) -> ActiveRunDecision:
|
||||
"""Lock Integration and return at most one active run."""
|
||||
...
|
||||
|
||||
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
|
||||
"""Load current identities, bindings, and eligible Contact facts."""
|
||||
...
|
||||
|
||||
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
|
||||
"""Apply one plan using its persisted sync run capture as CAS authority."""
|
||||
...
|
||||
|
||||
def resolve_effective_binding(
|
||||
self,
|
||||
*,
|
||||
integration_id: IntegrationId,
|
||||
provider: IMProvider,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
) -> BindingResolutionResult:
|
||||
"""Load and resolve one credential-free effective binding snapshot."""
|
||||
...
|
||||
|
||||
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
|
||||
"""Append diagnostic result facts without changing current state."""
|
||||
...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActiveRunDecision",
|
||||
"ActiveRunDecisionKind",
|
||||
"ApplyReconciliationResult",
|
||||
"ApplyReconciliationStatus",
|
||||
"IMControlPlaneRepository",
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Current IM identity and binding values shared by sync and resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OpaqueProviderPayload:
|
||||
"""Immutable provider JSON retained only for persistence diagnostics."""
|
||||
|
||||
_serialized: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, values: Mapping[str, JsonValue]) -> OpaqueProviderPayload:
|
||||
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
def to_mapping(self) -> dict[str, JsonValue]:
|
||||
value = json.loads(self._serialized)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("provider payload must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIdentity:
|
||||
"""Current provider identity independent from ORM lifetime and raw clients."""
|
||||
|
||||
id: IMIdentityId
|
||||
integration_id: IntegrationId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
normalized_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
raw_payload: OpaqueProviderPayload
|
||||
last_seen_sync_run_id: IMSyncRunId | None
|
||||
last_seen_at: UtcTimestamp | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.provider_user_id.strip():
|
||||
raise ValueError("provider user id must not be blank")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
identity_id: IMIdentityId,
|
||||
integration_id: IntegrationId,
|
||||
provider: IMProvider,
|
||||
provider_user_id: str,
|
||||
display_name: str | None,
|
||||
email: str | None,
|
||||
raw_payload: Mapping[str, JsonValue],
|
||||
last_seen_sync_run_id: IMSyncRunId | None,
|
||||
last_seen_at: UtcTimestamp | None,
|
||||
now: UtcTimestamp,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> IMIdentity:
|
||||
clean_name = display_name.strip() if display_name is not None else None
|
||||
clean_email = email.strip() if email is not None else None
|
||||
return cls(
|
||||
id=identity_id,
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_user_id=provider_user_id.strip(),
|
||||
display_name=clean_name,
|
||||
normalized_name=clean_name.casefold() if clean_name else None,
|
||||
email=clean_email,
|
||||
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
|
||||
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
|
||||
last_seen_sync_run_id=last_seen_sync_run_id,
|
||||
last_seen_at=last_seen_at,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMBinding:
|
||||
"""Current Contact-to-provider identity association in one resolution scope."""
|
||||
|
||||
id: IMBindingId
|
||||
integration_id: IntegrationId
|
||||
scope: IMBindingScope
|
||||
scope_id: str
|
||||
contact_id: ContactId
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
bound_by_account_id: AccountId | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.scope_id.strip():
|
||||
raise ValueError("binding scope id must not be blank")
|
||||
if self.scope is IMBindingScope.ORGANIZATION and self.scope_id != str(self.integration_id):
|
||||
raise ValueError("organization binding scope must be its integration")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
binding_id: IMBindingId,
|
||||
integration_id: IntegrationId,
|
||||
scope: IMBindingScope,
|
||||
scope_id: str,
|
||||
contact_id: ContactId,
|
||||
identity_id: IMIdentityId,
|
||||
provider: IMProvider,
|
||||
bound_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
created_at: UtcTimestamp | None = None,
|
||||
) -> IMBinding:
|
||||
return cls(
|
||||
id=binding_id,
|
||||
integration_id=integration_id,
|
||||
scope=scope,
|
||||
scope_id=scope_id,
|
||||
contact_id=contact_id,
|
||||
identity_id=identity_id,
|
||||
provider=provider,
|
||||
bound_by_account_id=bound_by_account_id,
|
||||
created_at=created_at or now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Explicit aggregate-load snapshot spanning IM persistence records."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .integration import IMIntegration
|
||||
from .records import IMBinding, IMIdentity
|
||||
from .sync_reconciliation import IMSyncRun, SyncResultFact
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMIntegrationState:
|
||||
"""Eagerly loaded Integration with mapped current and historical children."""
|
||||
|
||||
integration: IMIntegration
|
||||
identities: tuple[IMIdentity, ...]
|
||||
bindings: tuple[IMBinding, ...]
|
||||
sync_runs: tuple[IMSyncRun, ...]
|
||||
sync_results: tuple[SyncResultFact, ...]
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Pure provider directory matching and immutable reconciliation plans."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import JsonValue
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactIdentitySource, ContactSnapshot
|
||||
from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType, IMSyncRunStatus
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
)
|
||||
|
||||
from .integration import IntegrationRevisionToken
|
||||
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
|
||||
|
||||
|
||||
class MatchKind(StrEnum):
|
||||
"""Stable explanation for how one provider entry was classified."""
|
||||
|
||||
PROVIDER_USER_ID = "provider_user_id"
|
||||
NORMALIZED_EMAIL = "normalized_email"
|
||||
UNMATCHED = "unmatched"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderDirectoryEntry:
|
||||
"""Provider-neutral directory values consumed by the pure reconciler."""
|
||||
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
raw_payload: OpaqueProviderPayload
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
provider_user_id: str,
|
||||
display_name: str | None,
|
||||
email: str | None,
|
||||
raw_payload: dict[str, JsonValue],
|
||||
) -> ProviderDirectoryEntry:
|
||||
clean_email = email.strip() if email is not None else None
|
||||
return cls(
|
||||
provider_user_id=provider_user_id.strip(),
|
||||
display_name=display_name.strip() if display_name is not None else None,
|
||||
email=clean_email,
|
||||
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
|
||||
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationSnapshot:
|
||||
"""Coherent current facts loaded before provider entries are matched."""
|
||||
|
||||
identities: tuple[IMIdentity, ...] = ()
|
||||
bindings: tuple[IMBinding, ...] = ()
|
||||
contacts: tuple[ContactSnapshot, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationAction:
|
||||
"""One provider entry match without persistence side effects."""
|
||||
|
||||
entry: ProviderDirectoryEntry
|
||||
match_kind: MatchKind
|
||||
identity_id: IMIdentityId | None
|
||||
binding_id: IMBindingId | None
|
||||
contact_id: ContactId | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationPlan:
|
||||
"""Immutable plan whose captured revision must be checked again at apply."""
|
||||
|
||||
sync_run_id: IMSyncRunId
|
||||
integration_revision: IntegrationRevisionToken
|
||||
provider: IMProvider
|
||||
actions: tuple[ReconciliationAction, ...]
|
||||
removed_identity_ids: tuple[IMIdentityId, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncResultFact:
|
||||
"""Append-only outcome for one action, removed binding, or diagnostic.
|
||||
|
||||
Removing an identity emits one fact per removed binding so every scope
|
||||
override remains auditable. An identity without bindings emits one fact
|
||||
whose binding and Contact fields are absent.
|
||||
"""
|
||||
|
||||
id: IMSyncResultId
|
||||
integration_id: IntegrationId
|
||||
sync_run_id: IMSyncRunId
|
||||
result_type: IMSyncResultType
|
||||
provider_user_id: str | None
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
normalized_email: NormalizedEmail | None
|
||||
contact_id: ContactId | None
|
||||
identity_id: IMIdentityId | None
|
||||
binding_id: IMBindingId | None
|
||||
removal_reason: IMSyncRemovalReason | None
|
||||
reason_code: str | None
|
||||
reason_message: str | None
|
||||
directory_entry_payload: OpaqueProviderPayload | None
|
||||
contact_snapshot: SyncContactSnapshot | None
|
||||
identity_snapshot: SyncIdentitySnapshot | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncContactSnapshot:
|
||||
"""Immutable Contact display values retained by a historical result."""
|
||||
|
||||
contact_id: ContactId
|
||||
name: str
|
||||
email: str | None
|
||||
avatar_file_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SyncIdentitySnapshot:
|
||||
"""Immutable last-known provider identity retained after current deletion."""
|
||||
|
||||
identity_id: IMIdentityId
|
||||
provider: IMProvider
|
||||
provider_user_id: str
|
||||
display_name: str | None
|
||||
email: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IMSyncRun:
|
||||
"""Independent sync aggregate that captures one complete Integration token."""
|
||||
|
||||
id: IMSyncRunId
|
||||
integration_revision: IntegrationRevisionToken
|
||||
provider: IMProvider
|
||||
status: IMSyncRunStatus
|
||||
added_count: int
|
||||
not_matched_count: int
|
||||
failed_count: int
|
||||
removed_count: int
|
||||
skipped_count: int
|
||||
started_by_account_id: AccountId | None
|
||||
started_at: UtcTimestamp | None
|
||||
finished_at: UtcTimestamp | None
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
created_at: UtcTimestamp
|
||||
updated_at: UtcTimestamp
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider: IMProvider,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
) -> IMSyncRun:
|
||||
return cls(
|
||||
id=sync_run_id,
|
||||
integration_revision=integration_revision,
|
||||
provider=provider,
|
||||
status=IMSyncRunStatus.QUEUED,
|
||||
added_count=0,
|
||||
not_matched_count=0,
|
||||
failed_count=0,
|
||||
removed_count=0,
|
||||
skipped_count=0,
|
||||
started_by_account_id=started_by_account_id,
|
||||
started_at=None,
|
||||
finished_at=None,
|
||||
error_code=None,
|
||||
error_message=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)
|
||||
|
||||
def start(self, now: UtcTimestamp) -> IMSyncRun:
|
||||
if self.status is not IMSyncRunStatus.QUEUED:
|
||||
return self
|
||||
return replace(self, status=IMSyncRunStatus.RUNNING, started_at=now, updated_at=now)
|
||||
|
||||
|
||||
class SyncReconciler:
|
||||
"""Stateless matching policy with no provider or persistence dependencies.
|
||||
|
||||
Email fallback accepts available account-backed Contacts: EE Organization
|
||||
Accounts and CE/SaaS workspace members. External Contacts never participate.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def reconcile(
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
provider: IMProvider,
|
||||
entries: tuple[ProviderDirectoryEntry, ...],
|
||||
snapshot: ReconciliationSnapshot,
|
||||
) -> ReconciliationPlan:
|
||||
identities = {
|
||||
identity.provider_user_id: identity
|
||||
for identity in snapshot.identities
|
||||
if identity.integration_id == integration_revision.integration_id and identity.provider is provider
|
||||
}
|
||||
bindings_by_identity: dict[IMIdentityId, IMBinding] = {}
|
||||
for binding in sorted(snapshot.bindings, key=lambda item: item.scope.value, reverse=True):
|
||||
bindings_by_identity.setdefault(binding.identity_id, binding)
|
||||
contacts_by_email = {
|
||||
item.contact.normalized_email: item.contact
|
||||
for item in snapshot.contacts
|
||||
if item.account_available
|
||||
and item.contact.identity_source
|
||||
in (ContactIdentitySource.ORGANIZATION_ACCOUNT, ContactIdentitySource.WORKSPACE_MEMBER)
|
||||
and item.contact.normalized_email is not None
|
||||
}
|
||||
|
||||
actions: list[ReconciliationAction] = []
|
||||
seen_provider_user_ids: set[str] = set()
|
||||
for entry in entries:
|
||||
seen_provider_user_ids.add(entry.provider_user_id)
|
||||
identity = identities.get(entry.provider_user_id)
|
||||
if identity is not None:
|
||||
matched_binding = bindings_by_identity.get(identity.id)
|
||||
actions.append(
|
||||
ReconciliationAction(
|
||||
entry=entry,
|
||||
match_kind=MatchKind.PROVIDER_USER_ID,
|
||||
identity_id=identity.id,
|
||||
binding_id=matched_binding.id if matched_binding is not None else None,
|
||||
contact_id=matched_binding.contact_id if matched_binding is not None else None,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
contact = contacts_by_email.get(entry.normalized_email) if entry.normalized_email is not None else None
|
||||
actions.append(
|
||||
ReconciliationAction(
|
||||
entry=entry,
|
||||
match_kind=MatchKind.NORMALIZED_EMAIL if contact is not None else MatchKind.UNMATCHED,
|
||||
identity_id=None,
|
||||
binding_id=None,
|
||||
contact_id=contact.id if contact is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
removed = tuple(
|
||||
identity.id
|
||||
for identity in snapshot.identities
|
||||
if identity.integration_id == integration_revision.integration_id
|
||||
and identity.provider is provider
|
||||
and identity.provider_user_id not in seen_provider_user_ids
|
||||
)
|
||||
return ReconciliationPlan(sync_run_id, integration_revision, provider, tuple(actions), removed)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Stable Human Input v2 values shared across domain contexts.
|
||||
|
||||
This package contains infrastructure-free values only. Feature-specific ownership
|
||||
and lifecycle rules belong to their bounded context rather than this package.
|
||||
"""
|
||||
|
||||
from .values import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
ContactId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
DeploymentScope,
|
||||
DirectoryScope,
|
||||
EmailProviderId,
|
||||
EndUserId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
PlatformEntryId,
|
||||
SubmissionId,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountId",
|
||||
"AppId",
|
||||
"ApproverGrantId",
|
||||
"AuditEventId",
|
||||
"ContactId",
|
||||
"DeliveryAttemptId",
|
||||
"DeliveryEndpointId",
|
||||
"DeploymentScope",
|
||||
"DirectoryScope",
|
||||
"EmailProviderId",
|
||||
"EndUserId",
|
||||
"FormId",
|
||||
"IMBindingId",
|
||||
"IMIdentityId",
|
||||
"IMSyncResultId",
|
||||
"IMSyncRunId",
|
||||
"IntegrationId",
|
||||
"NormalizedEmail",
|
||||
"OTPChallengeId",
|
||||
"PlatformEntryId",
|
||||
"SubmissionId",
|
||||
"UploadCapabilityId",
|
||||
"UploadFileAssociationId",
|
||||
"UtcTimestamp",
|
||||
"WorkspaceId",
|
||||
"WorkspaceScope",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Primitive-independent identifiers, scopes, email, and time values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import override
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Identifier:
|
||||
"""Non-empty string identifier with explicit primitive serialization."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, str) or not self.value.strip():
|
||||
raise ValueError(f"{type(self).__name__} must not be blank")
|
||||
object.__setattr__(self, "value", self.value.strip())
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class AccountId(_Identifier):
|
||||
"""Identifier of an Account record."""
|
||||
|
||||
|
||||
class ContactId(_Identifier):
|
||||
"""Identifier of a canonical Contact."""
|
||||
|
||||
|
||||
class EndUserId(_Identifier):
|
||||
"""Identifier of one app-scoped EndUser identity."""
|
||||
|
||||
|
||||
class PlatformEntryId(_Identifier):
|
||||
"""Identifier of one Platform allow-list entry."""
|
||||
|
||||
|
||||
class WorkspaceId(_Identifier):
|
||||
"""Identifier of the workspace that owns or resolves a Contact."""
|
||||
|
||||
|
||||
class AppId(_Identifier):
|
||||
"""Identifier of the application that owns a Human Input form."""
|
||||
|
||||
|
||||
class FormId(_Identifier):
|
||||
"""Identifier of one Human Input v2 form root."""
|
||||
|
||||
|
||||
class ApproverGrantId(_Identifier):
|
||||
"""Identifier of one form-scoped approver grant."""
|
||||
|
||||
|
||||
class OTPChallengeId(_Identifier):
|
||||
"""Identifier of one grant-scoped OTP proof session."""
|
||||
|
||||
|
||||
class DeliveryEndpointId(_Identifier):
|
||||
"""Identifier of one frozen form delivery endpoint."""
|
||||
|
||||
|
||||
class DeliveryAttemptId(_Identifier):
|
||||
"""Identifier of one append-only delivery attempt."""
|
||||
|
||||
|
||||
class SubmissionId(_Identifier):
|
||||
"""Identifier of one immutable winning form submission."""
|
||||
|
||||
|
||||
class AuditEventId(_Identifier):
|
||||
"""Identifier of one append-only Human Input audit event."""
|
||||
|
||||
|
||||
class EmailProviderId(_Identifier):
|
||||
"""Identifier of one workspace email provider configuration."""
|
||||
|
||||
|
||||
class UploadCapabilityId(_Identifier):
|
||||
"""Identifier of one endpoint-scoped upload capability."""
|
||||
|
||||
|
||||
class UploadFileAssociationId(_Identifier):
|
||||
"""Identifier of one durable uploaded-file association."""
|
||||
|
||||
|
||||
class IntegrationId(_Identifier):
|
||||
"""Identifier of one IM Integration configuration identity."""
|
||||
|
||||
|
||||
class IMIdentityId(_Identifier):
|
||||
"""Identifier of one current synchronized provider identity."""
|
||||
|
||||
|
||||
class IMBindingId(_Identifier):
|
||||
"""Identifier of one current Contact-to-IM-identity binding."""
|
||||
|
||||
|
||||
class IMSyncRunId(_Identifier):
|
||||
"""Identifier of one IM directory synchronization run."""
|
||||
|
||||
|
||||
class IMSyncResultId(_Identifier):
|
||||
"""Identifier of one append-only synchronization result fact."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NormalizedEmail:
|
||||
"""Case-insensitive canonical email used for identity comparisons."""
|
||||
|
||||
value: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, str):
|
||||
raise ValueError("value must be a valid email")
|
||||
normalized = self.value.strip().casefold()
|
||||
local, separator, domain = normalized.partition("@")
|
||||
if not separator or not local or not domain or " " in normalized or "@" in domain:
|
||||
raise ValueError("value must be a valid email")
|
||||
object.__setattr__(self, "value", normalized)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeploymentScope:
|
||||
"""Deployment-wide owner scope used by EE Organization contacts."""
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "deployment"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceScope:
|
||||
"""Owner scope for workspace-owned contacts and directory operations."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
|
||||
def to_primitive(self) -> dict[str, str]:
|
||||
return {"kind": "workspace", "workspace_id": self.workspace_id.to_primitive()}
|
||||
|
||||
|
||||
type DirectoryScope = DeploymentScope | WorkspaceScope
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UtcTimestamp:
|
||||
"""Timezone-aware timestamp normalized to UTC at construction."""
|
||||
|
||||
value: datetime
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.value, datetime) or self.value.tzinfo is None or self.value.utcoffset() is None:
|
||||
raise ValueError("value must be a timezone-aware datetime")
|
||||
object.__setattr__(self, "value", self.value.astimezone(UTC))
|
||||
|
||||
@classmethod
|
||||
def now(cls) -> UtcTimestamp:
|
||||
return cls(datetime.now(UTC))
|
||||
|
||||
def to_primitive(self) -> str:
|
||||
return self.value.isoformat().replace("+00:00", "Z")
|
||||
@@ -14,6 +14,10 @@ from core.workflow.human_input_adapter import (
|
||||
EmailDeliveryMethod,
|
||||
EmailRecipients,
|
||||
ExternalRecipient,
|
||||
InstantMessageChannelRecipient,
|
||||
InstantMessageDeliveryConfig,
|
||||
InstantMessageDeliveryMethod,
|
||||
InstantMessageUserRecipient,
|
||||
InteractiveSurfaceDeliveryMethod,
|
||||
is_human_input_webapp_enabled,
|
||||
)
|
||||
@@ -32,6 +36,7 @@ from models.human_input import (
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
InstantMessageRecipientPayload,
|
||||
RecipientType,
|
||||
StandaloneWebAppRecipientPayload,
|
||||
)
|
||||
@@ -323,9 +328,48 @@ class HumanInputFormRepositoryImpl:
|
||||
recipients_config=email_recipients_config,
|
||||
)
|
||||
)
|
||||
case InstantMessageDeliveryMethod():
|
||||
recipients.extend(
|
||||
self._build_instant_message_recipients(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
config=delivery_method.config,
|
||||
)
|
||||
)
|
||||
|
||||
return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)
|
||||
|
||||
@staticmethod
|
||||
def _build_instant_message_recipients(
|
||||
*,
|
||||
form_id: str,
|
||||
delivery_id: str,
|
||||
config: InstantMessageDeliveryConfig,
|
||||
) -> list[HumanInputFormRecipient]:
|
||||
recipient_models: list[HumanInputFormRecipient] = []
|
||||
for recipient in config.recipients.items:
|
||||
match recipient:
|
||||
case InstantMessageChannelRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
channel_id=recipient.channel_id,
|
||||
)
|
||||
case InstantMessageUserRecipient():
|
||||
payload = InstantMessageRecipientPayload(
|
||||
provider=config.provider,
|
||||
recipient_kind=recipient.type,
|
||||
user_id=recipient.user_id,
|
||||
)
|
||||
recipient_models.append(
|
||||
HumanInputFormRecipient.new(
|
||||
form_id=form_id,
|
||||
delivery_id=delivery_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return recipient_models
|
||||
|
||||
def _build_email_recipients(
|
||||
self,
|
||||
session: Session,
|
||||
|
||||
@@ -5,6 +5,8 @@ Dify-owned field spellings and value shapes. Adapt them here before handing the
|
||||
payload to Graphon so Graphon-owned models only see current contracts.
|
||||
"""
|
||||
|
||||
# TODO(QuantumGhost): This should be migrated back to human_input.HumanInputNodeData.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
@@ -26,6 +28,7 @@ from graphon.variables.consts import SELECTORS_LENGTH
|
||||
class DeliveryMethodType(enum.StrEnum):
|
||||
WEBAPP = enum.auto()
|
||||
EMAIL = enum.auto()
|
||||
IM = enum.auto()
|
||||
|
||||
|
||||
class EmailRecipientType(enum.StrEnum):
|
||||
@@ -34,6 +37,17 @@ class EmailRecipientType(enum.StrEnum):
|
||||
EXTERNAL = "external"
|
||||
|
||||
|
||||
class InstantMessageProvider(enum.StrEnum):
|
||||
SLACK = "slack"
|
||||
TEAMS = "teams"
|
||||
DISCORD = "discord"
|
||||
|
||||
|
||||
class InstantMessageRecipientType(enum.StrEnum):
|
||||
CHANNEL = "channel"
|
||||
USER = "user"
|
||||
|
||||
|
||||
class _InteractiveSurfaceDeliveryConfig(BaseModel):
|
||||
pass
|
||||
|
||||
@@ -56,6 +70,25 @@ MemberRecipient = BoundRecipient
|
||||
EmailRecipient = Annotated[BoundRecipient | ExternalRecipient, Field(discriminator="type")]
|
||||
|
||||
|
||||
class InstantMessageChannelRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.CHANNEL] = InstantMessageRecipientType.CHANNEL
|
||||
channel_id: str
|
||||
|
||||
|
||||
class InstantMessageUserRecipient(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: Literal[InstantMessageRecipientType.USER] = InstantMessageRecipientType.USER
|
||||
user_id: str
|
||||
|
||||
|
||||
InstantMessageRecipient = Annotated[
|
||||
InstantMessageChannelRecipient | InstantMessageUserRecipient, Field(discriminator="type")
|
||||
]
|
||||
|
||||
|
||||
class EmailRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -66,6 +99,12 @@ class EmailRecipients(BaseModel):
|
||||
items: list[EmailRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class InstantMessageRecipients(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
items: list[InstantMessageRecipient] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmailDeliveryConfig(BaseModel):
|
||||
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
|
||||
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
|
||||
@@ -141,6 +180,14 @@ class EmailDeliveryConfig(BaseModel):
|
||||
return " ".join(sanitized.split())
|
||||
|
||||
|
||||
class InstantMessageDeliveryConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: InstantMessageProvider
|
||||
recipients: InstantMessageRecipients = Field(default_factory=InstantMessageRecipients)
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _DeliveryMethodBase(BaseModel):
|
||||
enabled: bool = True
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
||||
@@ -170,10 +217,31 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
|
||||
return selectors
|
||||
|
||||
|
||||
class InstantMessageDeliveryMethod(_DeliveryMethodBase):
|
||||
type: Literal[DeliveryMethodType.IM] = DeliveryMethodType.IM
|
||||
config: InstantMessageDeliveryConfig
|
||||
|
||||
@override
|
||||
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
|
||||
if not self.config.message:
|
||||
return ()
|
||||
variable_template_parser = VariableTemplateParser(template=self.config.message)
|
||||
selectors: list[Sequence[str]] = []
|
||||
for variable_selector in variable_template_parser.extract_variable_selectors():
|
||||
value_selector = list(variable_selector.value_selector)
|
||||
if len(value_selector) < SELECTORS_LENGTH:
|
||||
continue
|
||||
selectors.append(value_selector[:SELECTORS_LENGTH])
|
||||
return selectors
|
||||
|
||||
|
||||
WebAppDeliveryMethod = InteractiveSurfaceDeliveryMethod
|
||||
_WebAppDeliveryConfig = _InteractiveSurfaceDeliveryConfig
|
||||
|
||||
DeliveryChannelConfig = Annotated[InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
|
||||
DeliveryChannelConfig = Annotated[
|
||||
InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
_DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig])
|
||||
|
||||
@@ -204,9 +272,10 @@ def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseMod
|
||||
|
||||
config_mapping = _copy_mapping(method_mapping.get("config"))
|
||||
if config_mapping is not None:
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
if method_mapping.get("type") == DeliveryMethodType.EMAIL:
|
||||
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
|
||||
if recipients_mapping is not None:
|
||||
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
|
||||
method_mapping["config"] = config_mapping
|
||||
|
||||
normalized_methods.append(method_mapping)
|
||||
@@ -377,6 +446,13 @@ __all__ = [
|
||||
"EmailRecipientType",
|
||||
"EmailRecipients",
|
||||
"ExternalRecipient",
|
||||
"InstantMessageChannelRecipient",
|
||||
"InstantMessageDeliveryConfig",
|
||||
"InstantMessageDeliveryMethod",
|
||||
"InstantMessageProvider",
|
||||
"InstantMessageRecipientType",
|
||||
"InstantMessageRecipients",
|
||||
"InstantMessageUserRecipient",
|
||||
"MemberRecipient",
|
||||
"WebAppDeliveryMethod",
|
||||
"_WebAppDeliveryConfig",
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Annotated, Any, Literal, Self, assert_never, override
|
||||
|
||||
from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator
|
||||
|
||||
from core.workflow.human_input_adapter import DeliveryChannelConfig
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
from graphon.file.enums import FileTransferMethod, FileType
|
||||
@@ -372,3 +373,10 @@ def validate_human_input_submission(
|
||||
missing_list = ", ".join(missing_inputs)
|
||||
msg = f"Missing required inputs: {missing_list}"
|
||||
raise HumanInputSubmissionValidationError(msg)
|
||||
|
||||
|
||||
class HumanInputNodeDataFull(HumanInputNodeData):
|
||||
# This model is the full definition of HumanInputNodeData.
|
||||
#
|
||||
# The model above lacks some fields due to migration between Graphon and Dify. This model add them back.
|
||||
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list[DeliveryChannelConfig])
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import enum
|
||||
from collections.abc import Sequence
|
||||
from typing import Annotated, Final, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Discriminator, Field
|
||||
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, TimeoutUnit, UserActionConfig
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import BuiltinNodeTypes, NodeType
|
||||
|
||||
|
||||
class RecipientType(enum.StrEnum):
|
||||
CONTACT = enum.auto()
|
||||
DYNAMIC_EMAIL = enum.auto()
|
||||
ONETIME_EMAIL = enum.auto()
|
||||
INITIATOR = enum.auto()
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
type: Literal[RecipientType.CONTACT] = RecipientType.CONTACT
|
||||
|
||||
contact_id: str
|
||||
|
||||
|
||||
class DynamicEmail(BaseModel):
|
||||
type: Literal[RecipientType.DYNAMIC_EMAIL] = RecipientType.DYNAMIC_EMAIL
|
||||
|
||||
selector: Sequence[str]
|
||||
|
||||
|
||||
class OnetimeEmail(BaseModel):
|
||||
type: Literal[RecipientType.ONETIME_EMAIL] = RecipientType.ONETIME_EMAIL
|
||||
|
||||
email: str
|
||||
|
||||
|
||||
class Initiator(BaseModel):
|
||||
type: Literal[RecipientType.INITIATOR] = RecipientType.INITIATOR
|
||||
|
||||
|
||||
RecipientConfig = Annotated[Contact | DynamicEmail | OnetimeEmail | Initiator, Discriminator("type")]
|
||||
|
||||
|
||||
class MessageTemplateConfig(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class Channel(enum.StrEnum):
|
||||
EMAIL = enum.auto()
|
||||
FEISHU = IMProvider.FEISHU.value
|
||||
SLACK = IMProvider.SLACK.value
|
||||
DING_TALK = IMProvider.DING_TALK.value
|
||||
MS_TEAMS = IMProvider.MS_TEAMS.value
|
||||
WE_COM = IMProvider.WE_COM.value
|
||||
LARK = IMProvider.LARK.value
|
||||
|
||||
|
||||
class DebugModeConfig(BaseModel):
|
||||
enabled: bool = False
|
||||
channels: Sequence[Channel]
|
||||
|
||||
|
||||
HUMAN_INPUT_V2_VERSION: Final = "2"
|
||||
|
||||
|
||||
def _version_validator(version: str) -> str:
|
||||
if version != HUMAN_INPUT_V2_VERSION:
|
||||
raise ValueError(f"Human Input v2 requires version='{HUMAN_INPUT_V2_VERSION}'")
|
||||
return version
|
||||
|
||||
|
||||
class HumanInputNodeData(BaseNodeData):
|
||||
"""Human Input node data."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
type: NodeType = BuiltinNodeTypes.HUMAN_INPUT
|
||||
version: Annotated[str, AfterValidator(_version_validator)] = HUMAN_INPUT_V2_VERSION
|
||||
|
||||
recipients_spec: list[RecipientConfig]
|
||||
|
||||
message_template: MessageTemplateConfig
|
||||
debug_mode: DebugModeConfig
|
||||
|
||||
form_content: str = ""
|
||||
inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig])
|
||||
user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig])
|
||||
timeout: int = 36
|
||||
timeout_unit: TimeoutUnit = TimeoutUnit.HOUR
|
||||
@@ -0,0 +1,12 @@
|
||||
from pydantic import Field, PositiveInt
|
||||
|
||||
|
||||
class PaginationParamsMixin:
|
||||
page: PositiveInt = Field(1, description="1-based page number.")
|
||||
limit: PositiveInt = Field(default=20, le=100, description="Maximum number of records returned per page.")
|
||||
|
||||
|
||||
class PaginationResultMixin:
|
||||
limit: int = Field(description="Page size used for the current query.")
|
||||
total: int = Field(description="Total number of candidates matching the current query.")
|
||||
page: int = Field(description="Current 1-based page number.")
|
||||
@@ -0,0 +1,5 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"""add human input v2 contact directory
|
||||
|
||||
Revision ID: 5c8f1a2b3d4e
|
||||
Revises: d2825e7b9c10
|
||||
Create Date: 2026-07-25 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5c8f1a2b3d4e"
|
||||
down_revision = "d2825e7b9c10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_contacts",
|
||||
sa.Column("name", sa.String(length=255), nullable=False, comment="Display name shown in contact surfaces."),
|
||||
sa.Column(
|
||||
"normalized_name",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Lower-cased search value maintained by the application.",
|
||||
),
|
||||
sa.Column(
|
||||
"identity_source",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
comment="Immutable identity source that determines the Contact lifecycle owner.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Ownership boundary: null only for EE Organization contacts; otherwise the owning tenants.id for "
|
||||
"workspace-owned contacts. CE and SaaS must never persist a null value."
|
||||
),
|
||||
),
|
||||
sa.Column(
|
||||
"account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to accounts.id for an account-backed contact.",
|
||||
),
|
||||
sa.Column(
|
||||
"email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Current deliverable email address, when available.",
|
||||
),
|
||||
sa.Column(
|
||||
"normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Full lower-cased email used for equality matching.",
|
||||
),
|
||||
sa.Column(
|
||||
"avatar_file_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to upload_files.id for an external contact avatar.",
|
||||
),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_contacts_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "account_id", name="human_input_contacts_tenant_account_uq"),
|
||||
sa.UniqueConstraint("tenant_id", "normalized_email", name="human_input_contacts_tenant_email_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(identity_source = 'organization_account' AND tenant_id IS NULL AND account_id IS NOT NULL) OR "
|
||||
"(identity_source = 'workspace_member' AND tenant_id IS NOT NULL AND account_id IS NOT NULL) OR "
|
||||
"(identity_source = 'external' AND tenant_id IS NOT NULL AND account_id IS NULL)",
|
||||
name="identity_owner",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"identity_source <> 'external' OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="external_email",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="email_normalization_pair",
|
||||
),
|
||||
comment=(
|
||||
"Canonical Human Input contact identities. EE Organization Account contacts have tenant_id IS NULL; "
|
||||
"workspace-owned contacts have tenant_id = tenants.id; CE and SaaS must not create contacts with "
|
||||
"tenant_id IS NULL."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"human_input_contacts_tenant_normalized_name_idx",
|
||||
"human_input_contacts",
|
||||
["tenant_id", "normalized_name"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_contacts.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"added_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to accounts.id for the administrator who added this directory entry.",
|
||||
),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_platform_contact_workspace_entries_pkey"),
|
||||
sa.UniqueConstraint("tenant_id", "contact_id", name="hipcwe_tenant_contact_uq"),
|
||||
comment=(
|
||||
"EE-only workspace allow-list for Organization Account contacts. Workspace membership and External "
|
||||
"contact ownership must not create rows in this table."
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_tenant_created_at_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["tenant_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hipcwe_contact_id_idx",
|
||||
"human_input_platform_contact_workspace_entries",
|
||||
["contact_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_platform_contact_workspace_entries")
|
||||
op.drop_table("human_input_contacts")
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
"""add human input v2 im control plane
|
||||
|
||||
Revision ID: 6d9f2b4c5e7a
|
||||
Revises: 5c8f1a2b3d4e
|
||||
Create Date: 2026-07-25 11:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6d9f2b4c5e7a"
|
||||
down_revision = "5c8f1a2b3d4e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_im_integrations",
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured IM provider discriminator."),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Provider-specific encrypted credential model serialized as JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical tenants.id owner in CE/SaaS; null for an EE deployment-wide integration.",
|
||||
),
|
||||
sa.Column(
|
||||
"provider_tenant_id",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Confirmed provider-side organization or workspace identity.",
|
||||
),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Last connectivity diagnostic status."),
|
||||
sa.Column(
|
||||
"config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Monotonic configuration revision used with the integration ID for CAS.",
|
||||
),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for the latest configuration writer.",
|
||||
),
|
||||
sa.Column("callback_url", sa.String(length=1024), nullable=True, comment="Configured callback URL."),
|
||||
sa.Column(
|
||||
"safe_status_reason",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Operator-safe connection diagnostic.",
|
||||
),
|
||||
sa.Column("last_checked_at", sa.DateTime(), nullable=True, comment="Latest connectivity check timestamp."),
|
||||
*_default_fields("human_input_im_integrations"),
|
||||
sa.UniqueConstraint("tenant_id", name="human_input_im_integrations_tenant_uq"),
|
||||
sa.CheckConstraint("config_version > 0", name="config_version_positive"),
|
||||
comment="Organization-level Human Input IM integration configuration.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_identities",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Provider identity discriminator."),
|
||||
sa.Column(
|
||||
"provider_user_id", sa.String(length=255), nullable=False, comment="Provider user matching identity."
|
||||
),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Latest provider display name."),
|
||||
sa.Column("normalized_name", sa.String(length=255), nullable=True, comment="Case-folded provider name."),
|
||||
sa.Column("email", sa.String(length=320), nullable=True, comment="Latest provider email."),
|
||||
sa.Column(
|
||||
"normalized_email", sa.String(length=320), nullable=True, comment="Case-folded fallback matching email."
|
||||
),
|
||||
sa.Column(
|
||||
"raw_payload",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Opaque provider payload serialized as JSON text for diagnostics.",
|
||||
),
|
||||
sa.Column(
|
||||
"last_seen_sync_run_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical human_input_im_sync_runs.id that last observed this identity.",
|
||||
),
|
||||
sa.Column("last_seen_at", sa.DateTime(), nullable=True, comment="Timestamp last observed."),
|
||||
*_default_fields("human_input_im_identities"),
|
||||
sa.UniqueConstraint(
|
||||
"integration_id",
|
||||
"provider",
|
||||
"provider_user_id",
|
||||
name="human_input_im_identities_integration_provider_user_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
|
||||
name="email_normalization_pair",
|
||||
),
|
||||
comment="Current synchronized IM directory identities.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_provider_email_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "provider", "normalized_email"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_provider_name_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "provider", "normalized_name"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimi_integration_last_seen_run_idx",
|
||||
"human_input_im_identities",
|
||||
["integration_id", "last_seen_sync_run_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_bindings",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column("scope", sa.String(length=20), nullable=False, comment="Organization or workspace scope."),
|
||||
sa.Column("scope_id", models.types.StringUUID(), nullable=False, comment="Scope owner identity."),
|
||||
sa.Column("contact_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_contacts.id."),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_identities.id.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Denormalized provider discriminator."),
|
||||
sa.Column(
|
||||
"bound_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for an administrative override.",
|
||||
),
|
||||
*_default_fields("human_input_im_bindings"),
|
||||
sa.UniqueConstraint(
|
||||
"scope",
|
||||
"scope_id",
|
||||
"contact_id",
|
||||
"provider",
|
||||
name="human_input_im_bindings_scope_contact_provider_uq",
|
||||
),
|
||||
sa.UniqueConstraint("scope", "scope_id", "im_identity_id", name="human_input_im_bindings_scope_identity_uq"),
|
||||
sa.CheckConstraint(
|
||||
"scope <> 'organization' OR scope_id = integration_id",
|
||||
name="organization_scope_owner",
|
||||
),
|
||||
comment="Current organization binding or workspace override.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimb_integration_contact_provider_scope_idx",
|
||||
"human_input_im_bindings",
|
||||
["integration_id", "contact_id", "provider", "scope", "scope_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimb_identity_scope_idx",
|
||||
"human_input_im_bindings",
|
||||
["im_identity_id", "scope", "scope_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_runs",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_integrations.id owner.",
|
||||
),
|
||||
sa.Column(
|
||||
"integration_config_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Captured integration configuration revision.",
|
||||
),
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Captured provider discriminator."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Synchronization lifecycle state."),
|
||||
sa.Column("added_count", sa.Integer(), nullable=False, comment="Newly matched and bound entries."),
|
||||
sa.Column("not_matched_count", sa.Integer(), nullable=False, comment="Unmatched entries."),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, comment="Failed entries."),
|
||||
sa.Column(
|
||||
"removed_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
comment="Removed binding facts, including one unbound-identity fact when applicable.",
|
||||
),
|
||||
sa.Column("skipped_count", sa.Integer(), nullable=False, comment="Intentionally skipped entries."),
|
||||
sa.Column(
|
||||
"started_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for the trigger actor.",
|
||||
),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Worker start timestamp."),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
|
||||
sa.Column("error_code", sa.String(length=100), nullable=True, comment="Machine-readable terminal error."),
|
||||
sa.Column("error_message", models.types.LongText(), nullable=True, comment="Operator-safe terminal error."),
|
||||
*_default_fields("human_input_im_sync_runs"),
|
||||
sa.CheckConstraint("integration_config_version > 0", name="captured_version_positive"),
|
||||
sa.CheckConstraint(
|
||||
"added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 "
|
||||
"AND skipped_count >= 0",
|
||||
name="result_counts_nonnegative",
|
||||
),
|
||||
comment="Manual IM directory synchronization lifecycle and counts.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsr_integration_status_created_idx",
|
||||
"human_input_im_sync_runs",
|
||||
["integration_id", "status", "created_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_im_sync_results",
|
||||
sa.Column(
|
||||
"integration_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Denormalized logical human_input_im_integrations.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"sync_run_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_im_sync_runs.id.",
|
||||
),
|
||||
sa.Column("result_type", sa.String(length=20), nullable=False, comment="Stable result bucket."),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Observed provider user ID."),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Observed provider display name."),
|
||||
sa.Column("email", sa.String(length=320), nullable=True, comment="Observed provider email."),
|
||||
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized matching email."),
|
||||
sa.Column(
|
||||
"contact_id", models.types.StringUUID(), nullable=True, comment="Historical logical Contact identity."
|
||||
),
|
||||
sa.Column(
|
||||
"im_identity_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Historical logical IM identity.",
|
||||
),
|
||||
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical logical IM binding."),
|
||||
sa.Column("removal_reason", sa.String(length=32), nullable=True, comment="Stable removal reason."),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Machine-readable diagnostic reason."),
|
||||
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic."),
|
||||
sa.Column(
|
||||
"directory_entry_payload",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable provider entry JSON observed by this run.",
|
||||
),
|
||||
sa.Column(
|
||||
"contact_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable Contact display snapshot JSON.",
|
||||
),
|
||||
sa.Column(
|
||||
"identity_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable removed identity snapshot JSON.",
|
||||
),
|
||||
*_default_fields("human_input_im_sync_results"),
|
||||
comment="Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_run_type_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["sync_run_id", "result_type", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_contact_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "contact_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiimsres_integration_identity_created_idx",
|
||||
"human_input_im_sync_results",
|
||||
["integration_id", "im_identity_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_im_sync_results")
|
||||
op.drop_table("human_input_im_sync_runs")
|
||||
op.drop_table("human_input_im_bindings")
|
||||
op.drop_table("human_input_im_identities")
|
||||
op.drop_table("human_input_im_integrations")
|
||||
@@ -0,0 +1,290 @@
|
||||
"""add human input v2 form core
|
||||
|
||||
Revision ID: 8a1c4e7f9b2d
|
||||
Revises: 6d9f2b4c5e7a
|
||||
Create Date: 2026-07-25 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "8a1c4e7f9b2d"
|
||||
down_revision = "6d9f2b4c5e7a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_email_providers",
|
||||
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured email provider discriminator."),
|
||||
sa.Column("sender_email", sa.String(length=320), nullable=False, comment="Configured sender email address."),
|
||||
sa.Column(
|
||||
"encrypted_credentials",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Encrypted Resend credential Pydantic model serialized as JSON text.",
|
||||
),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("sender_name", sa.String(length=255), nullable=False, comment="Optional sender display name."),
|
||||
sa.Column(
|
||||
"configured_by_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to accounts.id for the latest configuration write.",
|
||||
),
|
||||
*_default_fields("human_input_email_providers"),
|
||||
sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"),
|
||||
comment="Workspace-level Human Input email delivery configuration.",
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_forms",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column(
|
||||
"form_definition",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Frozen Human Input v2 form definition serialized as JSON text.",
|
||||
),
|
||||
sa.Column("rendered_content", models.types.LongText(), nullable=False, comment="Frozen rendered content."),
|
||||
sa.Column("node_timeout_at", sa.DateTime(), nullable=False, comment="Frozen node-level timeout timestamp."),
|
||||
sa.Column("global_expires_at", sa.DateTime(), nullable=False, comment="Frozen global expiration timestamp."),
|
||||
sa.Column("form_kind", sa.String(length=20), nullable=False, comment="Human Input v2 form ownership kind."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Current form lifecycle state."),
|
||||
sa.Column(
|
||||
"workflow_pause_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to workflow_pauses.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"node_execution_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to workflow_node_executions.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_forms"),
|
||||
sa.UniqueConstraint("workflow_pause_id", name="hiv2_forms_workflow_pause_uq"),
|
||||
sa.UniqueConstraint("node_execution_id", name="hiv2_forms_node_execution_uq"),
|
||||
sa.CheckConstraint(
|
||||
"form_kind <> 'runtime' OR (workflow_pause_id IS NOT NULL AND node_execution_id IS NOT NULL)",
|
||||
name="runtime_owner",
|
||||
),
|
||||
comment="Independent Human Input v2 form roots bound only to shared workflow pause infrastructure.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_forms_tenant_status_node_timeout_idx",
|
||||
"human_input_v2_forms",
|
||||
["tenant_id", "status", "node_timeout_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_forms_tenant_status_global_expiry_idx",
|
||||
"human_input_v2_forms",
|
||||
["tenant_id", "status", "global_expires_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_approver_grants",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="Approval subject discriminator."),
|
||||
sa.Column("subject_key", sa.String(length=255), nullable=False, comment="Portable subject deduplication key."),
|
||||
sa.Column(
|
||||
"matched_sources",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Immutable ordered recipient source snapshots serialized as JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"subject_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Immutable display-only subject snapshot serialized as JSON text.",
|
||||
),
|
||||
sa.Column("contact_id", models.types.StringUUID(), nullable=True, comment="Logical human_input_contacts.id."),
|
||||
sa.Column("end_user_id", models.types.StringUUID(), nullable=True, comment="Logical end_users.id."),
|
||||
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized Email subject."),
|
||||
*_default_fields("human_input_v2_form_approver_grants"),
|
||||
sa.UniqueConstraint("form_id", "subject_key", name="hiv2_form_grants_form_subject_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type = 'contact' AND contact_id IS NOT NULL AND end_user_id IS NULL "
|
||||
"AND normalized_email IS NULL) OR "
|
||||
"(subject_type = 'end_user' AND contact_id IS NULL AND end_user_id IS NOT NULL "
|
||||
"AND normalized_email IS NULL) OR "
|
||||
"(subject_type = 'email_address' AND contact_id IS NULL AND end_user_id IS NULL "
|
||||
"AND normalized_email IS NOT NULL)",
|
||||
name="subject_identity",
|
||||
),
|
||||
comment="Frozen Human Input v2 form approval grants resolved from runtime recipients.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_contact_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "contact_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_end_user_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "end_user_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_grants_form_email_idx",
|
||||
"human_input_v2_form_approver_grants",
|
||||
["form_id", "normalized_email"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_delivery_endpoints",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("channel", sa.String(length=20), nullable=False, comment="Delivery or interaction channel."),
|
||||
sa.Column("address_hash", sa.String(length=64), nullable=False, comment="Canonical endpoint SHA-256."),
|
||||
sa.Column("email_address", sa.String(length=320), nullable=True, comment="Frozen Email endpoint address."),
|
||||
sa.Column("integration_id", models.types.StringUUID(), nullable=True, comment="Logical IM integration id."),
|
||||
sa.Column("provider", sa.String(length=20), nullable=True, comment="Frozen IM provider."),
|
||||
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Frozen provider user id."),
|
||||
sa.Column("provider_tenant_id", sa.String(length=255), nullable=True, comment="Frozen provider tenant id."),
|
||||
sa.Column("im_identity_id", models.types.StringUUID(), nullable=True, comment="Historical IM identity id."),
|
||||
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical IM binding id."),
|
||||
sa.Column("access_token_hash", sa.String(length=64), nullable=True, comment="Hashed endpoint capability."),
|
||||
*_default_fields("human_input_v2_form_delivery_endpoints"),
|
||||
sa.UniqueConstraint(
|
||||
"form_id",
|
||||
"approver_grant_id",
|
||||
"channel",
|
||||
"address_hash",
|
||||
name="hiv2_form_endpoints_grant_channel_address_uq",
|
||||
),
|
||||
sa.UniqueConstraint("access_token_hash", name="hiv2_form_endpoints_token_uq"),
|
||||
comment="Immutable notification and interaction endpoints for Human Input v2 approver grants.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_endpoints_identity_form_idx",
|
||||
"human_input_v2_form_delivery_endpoints",
|
||||
["im_identity_id", "form_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("attempt_number", sa.Integer(), nullable=False, comment="One-based endpoint retry sequence."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Delivery attempt lifecycle."),
|
||||
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="Eligibility timestamp."),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Provider delivery start timestamp."),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
|
||||
sa.Column("provider_message_id", sa.String(length=255), nullable=True, comment="Provider message id."),
|
||||
sa.Column("failure_code", sa.String(length=100), nullable=True, comment="Failure code."),
|
||||
sa.Column("failure_reason", models.types.LongText(), nullable=True, comment="Failure diagnostic."),
|
||||
sa.Column("provider_response", models.types.LongText(), nullable=True, comment="Provider response JSON."),
|
||||
*_default_fields("human_input_v2_form_delivery_attempts"),
|
||||
sa.UniqueConstraint("endpoint_id", "attempt_number", name="hiv2_form_attempts_endpoint_number_uq"),
|
||||
comment="Append-oriented delivery attempts for Human Input v2 form endpoints.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_attempts_form_status_created_idx",
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
["form_id", "status", "created_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_attempts_status_scheduled_idx",
|
||||
"human_input_v2_form_delivery_attempts",
|
||||
["status", "scheduled_at", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_upload_tokens",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("upload_token_hash", sa.String(length=64), nullable=False, comment="Hashed upload capability."),
|
||||
*_default_fields("human_input_v2_form_upload_tokens"),
|
||||
sa.UniqueConstraint("upload_token_hash", name="hiv2_form_upload_tokens_hash_uq"),
|
||||
comment="Hashed endpoint-scoped upload capabilities for Human Input v2 forms.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_tokens_form_endpoint_idx",
|
||||
"human_input_v2_form_upload_tokens",
|
||||
["form_id", "endpoint_id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_upload_files",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
|
||||
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("upload_file_id", models.types.StringUUID(), nullable=False, comment="Logical upload_files.id."),
|
||||
sa.Column(
|
||||
"upload_token_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical human_input_v2_form_upload_tokens.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_upload_files"),
|
||||
sa.UniqueConstraint("upload_file_id", name="hiv2_form_upload_files_file_uq"),
|
||||
comment="Durable Human Input v2 form, endpoint, upload-token, and file associations.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_files_form_endpoint_idx",
|
||||
"human_input_v2_form_upload_files",
|
||||
["form_id", "endpoint_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_upload_files_token_idx",
|
||||
"human_input_v2_form_upload_files",
|
||||
["upload_token_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_upload_files")
|
||||
op.drop_table("human_input_v2_form_upload_tokens")
|
||||
op.drop_table("human_input_v2_form_delivery_attempts")
|
||||
op.drop_table("human_input_v2_form_delivery_endpoints")
|
||||
op.drop_table("human_input_v2_form_approver_grants")
|
||||
op.drop_table("human_input_v2_forms")
|
||||
op.drop_table("human_input_email_providers")
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
"""add human input v2 otp proof session
|
||||
|
||||
Revision ID: 9c2e5f7a1b3d
|
||||
Revises: 8a1c4e7f9b2d
|
||||
Create Date: 2026-07-25 13:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9c2e5f7a1b3d"
|
||||
down_revision = "8a1c4e7f9b2d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_v2_form_otp_challenges",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="OTP proof subject discriminator."),
|
||||
sa.Column(
|
||||
"challenge_token_hash",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
comment="SHA-256 hash of the ephemeral challenge token.",
|
||||
),
|
||||
sa.Column(
|
||||
"code_hash",
|
||||
sa.String(length=255),
|
||||
nullable=False,
|
||||
comment="Slow password hash of the one-time verification code.",
|
||||
),
|
||||
sa.Column(
|
||||
"code_hash_algorithm",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
comment="Verifier algorithm discriminator for code_hash.",
|
||||
),
|
||||
sa.Column("email_hash", sa.String(length=64), nullable=False, comment="SHA-256 of the normalized Email."),
|
||||
sa.Column("email", sa.String(length=320), nullable=False, comment="Normalized destination Email."),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, comment="Current proof-session usability."),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Challenge expiration timestamp."),
|
||||
sa.Column("resend_after", sa.DateTime(), nullable=False, comment="Earliest replacement timestamp."),
|
||||
sa.Column(
|
||||
"contact_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical human_input_contacts.id captured for a Contact incarnation.",
|
||||
),
|
||||
sa.Column("send_count", sa.Integer(), nullable=False, comment="One-based send count for the grant scope."),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, comment="Consumed verification attempts."),
|
||||
sa.Column("verified_at", sa.DateTime(), nullable=True, comment="Successful verification timestamp."),
|
||||
sa.Column("invalidated_at", sa.DateTime(), nullable=True, comment="Replacement or stale-identity timestamp."),
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="human_input_v2_form_otp_challenges_pkey"),
|
||||
sa.UniqueConstraint("challenge_token_hash", name="hiv2_form_otp_challenges_token_uq"),
|
||||
sa.CheckConstraint(
|
||||
"(subject_type = 'contact' AND contact_id IS NOT NULL) OR "
|
||||
"(subject_type = 'email_address' AND contact_id IS NULL)",
|
||||
name="hiv2_form_otp_challenges_subject_identity_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"send_count >= 1 AND send_count <= 5",
|
||||
name="hiv2_form_otp_challenges_send_count_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"attempt_count >= 0 AND attempt_count <= 5",
|
||||
name="hiv2_form_otp_challenges_attempt_count_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(status = 'verified' AND verified_at IS NOT NULL AND invalidated_at IS NULL) OR "
|
||||
"(status = 'invalidated' AND verified_at IS NULL AND invalidated_at IS NOT NULL) OR "
|
||||
"(status IN ('pending', 'expired') AND verified_at IS NULL AND invalidated_at IS NULL)",
|
||||
name="hiv2_form_otp_challenges_terminal_timestamps_ck",
|
||||
),
|
||||
comment="Hashed OTP proof sessions for Email-based Human Input v2 approval.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_otp_scope_created_idx",
|
||||
"human_input_v2_form_otp_challenges",
|
||||
["tenant_id", "form_id", "approver_grant_id", "created_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_otp_challenges")
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
"""add human input v2 submission runtime
|
||||
|
||||
Revision ID: ad4f6b8c2e1d
|
||||
Revises: 9c2e5f7a1b3d
|
||||
Create Date: 2026-07-25 14:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ad4f6b8c2e1d"
|
||||
down_revision = "9c2e5f7a1b3d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
|
||||
return (
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"human_input_v2_form_audit_events",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column("event_type", sa.String(length=64), nullable=False, comment="Stable append-only event name."),
|
||||
sa.Column("occurred_at", sa.DateTime(), nullable=False, comment="Business timestamp for the audited fact."),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
sa.Column("channel", sa.String(length=20), nullable=True, comment="Originating interaction channel."),
|
||||
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Stable rejection reason code."),
|
||||
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic detail."),
|
||||
sa.Column(
|
||||
"authorization_proof",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Secret-free verified proof serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"event_payload",
|
||||
models.types.LongText(),
|
||||
nullable=True,
|
||||
comment="Immutable event-specific structured JSON text.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_audit_events"),
|
||||
sa.CheckConstraint(
|
||||
"event_type <> 'submission_authorized' OR "
|
||||
"(approver_grant_id IS NOT NULL AND authorization_proof IS NOT NULL)",
|
||||
name="hiv2_form_audit_authorized_proof_ck",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"event_type <> 'submission_rejected' OR reason_code IS NOT NULL",
|
||||
name="hiv2_form_audit_rejection_reason_ck",
|
||||
),
|
||||
comment="Append-only Human Input v2 audit facts for proof sessions and submission authorization.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_audit_form_occurred_idx",
|
||||
"human_input_v2_form_audit_events",
|
||||
["form_id", "occurred_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_audit_tenant_occurred_idx",
|
||||
"human_input_v2_form_audit_events",
|
||||
["tenant_id", "occurred_at", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"human_input_v2_form_submissions",
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
|
||||
sa.Column(
|
||||
"form_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_forms.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"approver_grant_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
|
||||
),
|
||||
sa.Column("actor_type", sa.String(length=20), nullable=False, comment="Submission actor discriminator."),
|
||||
sa.Column(
|
||||
"authorization_audit_event_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=False,
|
||||
comment="Logical foreign key to the authorized human_input_v2_form_audit_events.id.",
|
||||
),
|
||||
sa.Column(
|
||||
"selected_action_id",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
comment="Selected action from the frozen form definition.",
|
||||
),
|
||||
sa.Column(
|
||||
"input_snapshot",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Unvalidated request.inputs object serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column(
|
||||
"canonical_values",
|
||||
models.types.LongText(),
|
||||
nullable=False,
|
||||
comment="Validated runtime values serialized as structured JSON text.",
|
||||
),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=False, comment="Winning commit business timestamp."),
|
||||
sa.Column(
|
||||
"actor_account_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical accounts.id for an Account actor.",
|
||||
),
|
||||
sa.Column(
|
||||
"actor_end_user_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical end_users.id for an EndUser actor.",
|
||||
),
|
||||
sa.Column(
|
||||
"actor_normalized_email",
|
||||
sa.String(length=320),
|
||||
nullable=True,
|
||||
comment="Normalized EmailAddress actor identity.",
|
||||
),
|
||||
sa.Column(
|
||||
"endpoint_id",
|
||||
models.types.StringUUID(),
|
||||
nullable=True,
|
||||
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
|
||||
),
|
||||
*_default_fields("human_input_v2_form_submissions"),
|
||||
sa.UniqueConstraint("form_id", name="hiv2_form_submissions_form_uq"),
|
||||
sa.UniqueConstraint(
|
||||
"authorization_audit_event_id",
|
||||
name="hiv2_submission_authorization_audit_event_uq",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(actor_type = 'account' AND actor_account_id IS NOT NULL AND actor_end_user_id IS NULL "
|
||||
"AND actor_normalized_email IS NULL) OR "
|
||||
"(actor_type = 'end_user' AND actor_account_id IS NULL AND actor_end_user_id IS NOT NULL "
|
||||
"AND actor_normalized_email IS NULL) OR "
|
||||
"(actor_type = 'email_address' AND actor_account_id IS NULL AND actor_end_user_id IS NULL "
|
||||
"AND actor_normalized_email IS NOT NULL)",
|
||||
name="hiv2_form_submissions_actor_identity_ck",
|
||||
),
|
||||
comment="Immutable first successful Human Input v2 submission and business actor.",
|
||||
)
|
||||
op.create_index(
|
||||
"hiv2_form_submissions_tenant_submitted_idx",
|
||||
"human_input_v2_form_submissions",
|
||||
["tenant_id", "submitted_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("human_input_v2_form_submissions")
|
||||
op.drop_table("human_input_v2_form_audit_events")
|
||||
@@ -66,6 +66,50 @@ from .enums import (
|
||||
)
|
||||
from .execution_extra_content import ExecutionExtraContent, HumanInputContent
|
||||
from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken
|
||||
from .human_input_v2 import (
|
||||
AccountSessionAuthorizationProof,
|
||||
DingTalkIMIntegrationEncryptedCredentials,
|
||||
EmailOTPAuthorizationProof,
|
||||
FeishuIMIntegrationEncryptedCredentials,
|
||||
FormApproverGrantMatchedSources,
|
||||
FormApproverGrantSubjectSnapshot,
|
||||
FormAuditEventPayload,
|
||||
FormAuthorizationProof,
|
||||
FormCanonicalValues,
|
||||
FormDeliveryProviderResponse,
|
||||
FormInputSnapshot,
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputEmailProvider,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormAuditEvent,
|
||||
HumanInputV2FormDefinition,
|
||||
HumanInputV2FormDeliveryAttempt,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
HumanInputV2FormOTPChallenge,
|
||||
HumanInputV2FormSubmission,
|
||||
HumanInputV2FormUploadFile,
|
||||
HumanInputV2FormUploadToken,
|
||||
IMIdentityAuthorizationProof,
|
||||
IMIdentityRawPayload,
|
||||
IMIntegrationEncryptedCredentials,
|
||||
IMSyncContactSnapshot,
|
||||
IMSyncDirectoryEntryPayload,
|
||||
IMSyncIdentitySnapshot,
|
||||
LarkIMIntegrationEncryptedCredentials,
|
||||
MSTeamsIMIntegrationEncryptedCredentials,
|
||||
ResendEmailProviderEncryptedCredentials,
|
||||
SlackIMIntegrationEncryptedCredentials,
|
||||
TrustedEndUserAuthorizationProof,
|
||||
WeComIMIntegrationEncryptedCredentials,
|
||||
)
|
||||
from .model import (
|
||||
AccountTrialAppRecord,
|
||||
ApiRequest,
|
||||
@@ -155,6 +199,7 @@ __all__ = [
|
||||
"APIBasedExtensionPoint",
|
||||
"Account",
|
||||
"AccountIntegrate",
|
||||
"AccountSessionAuthorizationProof",
|
||||
"AccountStatus",
|
||||
"AccountStepByStepTourState",
|
||||
"AccountTrialAppRecord",
|
||||
@@ -211,22 +256,59 @@ __all__ = [
|
||||
"DatasourceOauthParamConfig",
|
||||
"DatasourceProvider",
|
||||
"DifySetup",
|
||||
"DingTalkIMIntegrationEncryptedCredentials",
|
||||
"Document",
|
||||
"DocumentSegment",
|
||||
"EmailOTPAuthorizationProof",
|
||||
"Embedding",
|
||||
"EndUser",
|
||||
"ExecutionExtraContent",
|
||||
"ExporleBanner",
|
||||
"ExternalKnowledgeApis",
|
||||
"ExternalKnowledgeBindings",
|
||||
"FeishuIMIntegrationEncryptedCredentials",
|
||||
"FormApproverGrantMatchedSources",
|
||||
"FormApproverGrantSubjectSnapshot",
|
||||
"FormAuditEventPayload",
|
||||
"FormAuthorizationProof",
|
||||
"FormCanonicalValues",
|
||||
"FormDeliveryProviderResponse",
|
||||
"FormInputSnapshot",
|
||||
"HumanInputContact",
|
||||
"HumanInputContactIdentitySource",
|
||||
"HumanInputContent",
|
||||
"HumanInputEmailProvider",
|
||||
"HumanInputForm",
|
||||
"HumanInputFormUploadFile",
|
||||
"HumanInputFormUploadToken",
|
||||
"HumanInputIMBinding",
|
||||
"HumanInputIMIdentity",
|
||||
"HumanInputIMIntegration",
|
||||
"HumanInputIMSyncResult",
|
||||
"HumanInputIMSyncRun",
|
||||
"HumanInputPlatformContactWorkspaceEntry",
|
||||
"HumanInputV2Form",
|
||||
"HumanInputV2FormApproverGrant",
|
||||
"HumanInputV2FormAuditEvent",
|
||||
"HumanInputV2FormDefinition",
|
||||
"HumanInputV2FormDeliveryAttempt",
|
||||
"HumanInputV2FormDeliveryEndpoint",
|
||||
"HumanInputV2FormOTPChallenge",
|
||||
"HumanInputV2FormSubmission",
|
||||
"HumanInputV2FormUploadFile",
|
||||
"HumanInputV2FormUploadToken",
|
||||
"IMIdentityAuthorizationProof",
|
||||
"IMIdentityRawPayload",
|
||||
"IMIntegrationEncryptedCredentials",
|
||||
"IMSyncContactSnapshot",
|
||||
"IMSyncDirectoryEntryPayload",
|
||||
"IMSyncIdentitySnapshot",
|
||||
"IconType",
|
||||
"InstalledApp",
|
||||
"InvitationCode",
|
||||
"LarkIMIntegrationEncryptedCredentials",
|
||||
"LoadBalancingModelConfig",
|
||||
"MSTeamsIMIntegrationEncryptedCredentials",
|
||||
"Message",
|
||||
"MessageAgentThought",
|
||||
"MessageAnnotation",
|
||||
@@ -244,8 +326,10 @@ __all__ = [
|
||||
"ProviderQuotaType",
|
||||
"ProviderType",
|
||||
"RecommendedApp",
|
||||
"ResendEmailProviderEncryptedCredentials",
|
||||
"SavedMessage",
|
||||
"Site",
|
||||
"SlackIMIntegrationEncryptedCredentials",
|
||||
"SnippetType",
|
||||
"Tag",
|
||||
"TagBinding",
|
||||
@@ -266,7 +350,9 @@ __all__ = [
|
||||
"TriggerOAuthSystemClient",
|
||||
"TriggerOAuthTenantClient",
|
||||
"TriggerSubscription",
|
||||
"TrustedEndUserAuthorizationProof",
|
||||
"UploadFile",
|
||||
"WeComIMIntegrationEncryptedCredentials",
|
||||
"Whitelist",
|
||||
"Workflow",
|
||||
"WorkflowAgentBindingType",
|
||||
|
||||
@@ -2,7 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
from sqlalchemy import MetaData
|
||||
|
||||
POSTGRES_INDEXES_NAMING_CONVENTION = {
|
||||
"ix": "%(column_0_label)s_idx",
|
||||
"ix": "%(table_name)s_%(column_0_N_name)s_idx",
|
||||
"uq": "%(table_name)s_%(column_0_name)s_key",
|
||||
"ck": "%(table_name)s_%(constraint_name)s_check",
|
||||
"fk": "%(table_name)s_%(column_0_name)s_fkey",
|
||||
|
||||
@@ -3,10 +3,10 @@ from enum import StrEnum
|
||||
from typing import Annotated, Literal, Self, final
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from core.workflow.human_input_adapter import DeliveryMethodType
|
||||
from core.workflow.human_input_adapter import DeliveryMethodType, InstantMessageProvider, InstantMessageRecipientType
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from libs.helper import generate_string
|
||||
|
||||
@@ -138,6 +138,7 @@ class ApprovalChannel(StrEnum):
|
||||
"""Where a paused human input form can be approved, surfaced to API callers."""
|
||||
|
||||
EMAIL = "email"
|
||||
IM = "im"
|
||||
WEB_APP = "web_app"
|
||||
CONSOLE = "console"
|
||||
|
||||
@@ -146,6 +147,7 @@ class RecipientType(StrEnum):
|
||||
# Second value = the approval channel this recipient maps to (surfaced in `approval_channels`).
|
||||
EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL
|
||||
EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL
|
||||
INSTANT_MESSAGE = "instant_message", ApprovalChannel.IM
|
||||
# STANDALONE_WEB_APP is used by the standalone web app.
|
||||
#
|
||||
# It's not used while running workflows / chatflows containing HumanInput
|
||||
@@ -185,6 +187,26 @@ class EmailExternalRecipientPayload(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
@final
|
||||
class InstantMessageRecipientPayload(BaseModel):
|
||||
TYPE: Literal[RecipientType.INSTANT_MESSAGE] = RecipientType.INSTANT_MESSAGE
|
||||
provider: InstantMessageProvider
|
||||
recipient_kind: InstantMessageRecipientType
|
||||
channel_id: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target(self) -> Self:
|
||||
match self.recipient_kind:
|
||||
case InstantMessageRecipientType.CHANNEL:
|
||||
if self.channel_id is None or self.user_id is not None:
|
||||
raise ValueError("channel recipients must include channel_id and must not include user_id")
|
||||
case InstantMessageRecipientType.USER:
|
||||
if self.user_id is None or self.channel_id is not None:
|
||||
raise ValueError("user recipients must include user_id and must not include channel_id")
|
||||
return self
|
||||
|
||||
|
||||
@final
|
||||
class StandaloneWebAppRecipientPayload(BaseModel):
|
||||
TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP
|
||||
@@ -211,6 +233,7 @@ class ConsoleDeliveryPayload(BaseModel):
|
||||
RecipientPayload = Annotated[
|
||||
EmailMemberRecipientPayload
|
||||
| EmailExternalRecipientPayload
|
||||
| InstantMessageRecipientPayload
|
||||
| StandaloneWebAppRecipientPayload
|
||||
| ConsoleRecipientPayload
|
||||
| BackstageRecipientPayload,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+132
-3
@@ -1,15 +1,16 @@
|
||||
import enum
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, cast, override
|
||||
from typing import Any, cast, overload, override
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator
|
||||
from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT
|
||||
from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
from sqlalchemy.sql.type_api import TypeEngine
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
@@ -69,8 +70,24 @@ class LongText(TypeDecorator[str | None]):
|
||||
return value
|
||||
|
||||
|
||||
@deprecated(
|
||||
"JSONModelColumn is a legacy coercing TEXT-backed type. "
|
||||
"Use FrozenPydanticModelColumn for new model-only persistence."
|
||||
)
|
||||
class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]):
|
||||
"""Store a Pydantic model as dialect-adjusted LongText JSON."""
|
||||
"""Store coercible Pydantic model input as dialect-adjusted LongText JSON.
|
||||
|
||||
New model-only columns should use ``FrozenPydanticModelColumn``. Existing
|
||||
uses must not be replaced mechanically because they may depend on:
|
||||
|
||||
- historical ``dict`` or JSON string writes;
|
||||
- non-strict validation and coercion;
|
||||
- Pydantic models that are not frozen;
|
||||
- compatibility with dirty or otherwise non-canonical stored data.
|
||||
|
||||
Audit both callers and persisted values before migrating an existing
|
||||
column.
|
||||
"""
|
||||
|
||||
impl = TEXT
|
||||
cache_ok = True
|
||||
@@ -171,6 +188,118 @@ class AdjustedJSON(TypeDecorator[dict | list | None]):
|
||||
return value
|
||||
|
||||
|
||||
class FrozenPydanticModelColumn[T: BaseModel](TypeDecorator[T]):
|
||||
"""Persist a frozen Pydantic model as JSON in a dialect-adjusted text column.
|
||||
|
||||
Binding serializes the accepted model directly with
|
||||
``model_dump_json(warnings="error")``. Loading validates the stored JSON in
|
||||
strict mode and lets Pydantic validation errors propagate to the caller.
|
||||
Pass a concrete model class for concrete models. For discriminated unions,
|
||||
pass a ``TypeAdapter`` together with every allowed concrete model class.
|
||||
|
||||
The backing type is ``TEXT`` on PostgreSQL and SQLite and ``LONGTEXT`` on
|
||||
MySQL. This is intentional: ``model_dump_json`` already returns JSON text,
|
||||
while passing that string through SQLAlchemy's ``JSON`` type would encode it
|
||||
again and persist a JSON string scalar instead of the model's JSON object.
|
||||
|
||||
SQLAlchemy does not track in-place changes made inside a Pydantic model, so
|
||||
this type only accepts models configured with ``frozen=True``. Persisted
|
||||
values must be updated by constructing a new model and assigning it to the
|
||||
ORM attribute as a whole, for example ``record.payload = PayloadModel(...)``.
|
||||
Otherwise the attribute may not be marked dirty and the change may not be
|
||||
persisted.
|
||||
|
||||
Pydantic freezing is shallow: nested mutable containers such as ``dict`` and
|
||||
``list`` can still be changed in place. Callers must treat nested values as
|
||||
immutable too instead of mutating paths such as
|
||||
``record.payload.root["key"]``. Supporting nested in-place mutation would
|
||||
require SQLAlchemy's Mutable extension or explicit deep change tracking,
|
||||
which this type does not provide.
|
||||
"""
|
||||
|
||||
impl = TEXT
|
||||
cache_ok = True
|
||||
|
||||
_model_type: type[T] | None
|
||||
_adapter: TypeAdapter[T] | None
|
||||
model_types: tuple[type[BaseModel], ...]
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
model_type: type[T],
|
||||
/,
|
||||
) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
adapter: TypeAdapter[T],
|
||||
/,
|
||||
*,
|
||||
model_types: tuple[type[BaseModel], ...],
|
||||
) -> None: ...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: type[T] | TypeAdapter[T],
|
||||
/,
|
||||
*,
|
||||
model_types: tuple[type[BaseModel], ...] | None = None,
|
||||
) -> None:
|
||||
if isinstance(schema, TypeAdapter):
|
||||
if model_types is None:
|
||||
raise TypeError("model_types is required when schema is a TypeAdapter")
|
||||
if not model_types:
|
||||
raise ValueError("model_types must not be empty")
|
||||
self._model_type = None
|
||||
self._adapter = schema
|
||||
self.model_types = model_types
|
||||
else:
|
||||
if model_types is not None:
|
||||
raise TypeError("model_types must not be provided for a concrete model")
|
||||
self._model_type = schema
|
||||
self._adapter = None
|
||||
self.model_types = (schema,)
|
||||
|
||||
for allowed_model_type in self.model_types:
|
||||
if not isinstance(allowed_model_type, type) or not issubclass(allowed_model_type, BaseModel):
|
||||
raise TypeError("model_types must contain only Pydantic BaseModel classes")
|
||||
model_name = f"{allowed_model_type.__module__}.{allowed_model_type.__qualname__}"
|
||||
if allowed_model_type.model_config.get("frozen") is not True:
|
||||
raise TypeError(f"{model_name} must configure frozen=True")
|
||||
if allowed_model_type.model_config.get("strict") is not True:
|
||||
raise TypeError(f"{model_name} must configure strict=True")
|
||||
super().__init__()
|
||||
|
||||
@override
|
||||
def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
|
||||
if dialect.name == "mysql":
|
||||
return dialect.type_descriptor(LONGTEXT())
|
||||
return dialect.type_descriptor(TEXT())
|
||||
|
||||
@override
|
||||
def process_bind_param(self, value: object | None, dialect: Dialect) -> str | None:
|
||||
del dialect
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, BaseModel) or not isinstance(value, self.model_types):
|
||||
allowed_model_names = ", ".join(model_type.__name__ for model_type in self.model_types)
|
||||
raise TypeError(f"value must be one of these Pydantic models: {allowed_model_names}")
|
||||
return value.model_dump_json(warnings="error")
|
||||
|
||||
@override
|
||||
def process_result_value(self, value: str | bytes | bytearray | None, dialect: Dialect) -> T | None:
|
||||
del dialect
|
||||
if value is None:
|
||||
return None
|
||||
if self._model_type is not None:
|
||||
return self._model_type.model_validate_json(value, strict=True)
|
||||
if self._adapter is None:
|
||||
raise RuntimeError("Pydantic JSON validator is not configured")
|
||||
return self._adapter.validate_json(value, strict=True)
|
||||
|
||||
|
||||
class EnumText[T: enum.StrEnum](TypeDecorator[T | None]):
|
||||
impl = VARCHAR
|
||||
cache_ok = True
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistence adapters for Human Input v2 bounded contexts."""
|
||||
@@ -0,0 +1,15 @@
|
||||
"""SQLAlchemy persistence adapter for Human Input v2 OTP proof sessions."""
|
||||
|
||||
from .repository import (
|
||||
OTPChallengeAuditFact,
|
||||
OTPChallengeAuditWriter,
|
||||
OTPPersistenceError,
|
||||
SQLAlchemyOTPChallengeRepository,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"OTPChallengeAuditFact",
|
||||
"OTPChallengeAuditWriter",
|
||||
"OTPPersistenceError",
|
||||
"SQLAlchemyOTPChallengeRepository",
|
||||
]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Explicit mapping between OTP proof-session values and ORM records.
|
||||
|
||||
Hash material crosses only this persistence boundary. Public challenge and proof
|
||||
serialization deliberately omit every code and token digest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ContactOTPSubject,
|
||||
EmailAddressOTPSubject,
|
||||
EmailOTPSubject,
|
||||
FormRef,
|
||||
OTPChallenge,
|
||||
OTPCodeHash,
|
||||
VerifiedEmailOTPProof,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputOTPChallengeStatus
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
ContactId,
|
||||
FormId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from models.human_input_v2 import EmailOTPAuthorizationProof, HumanInputV2FormOTPChallenge
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> UtcTimestamp:
|
||||
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
|
||||
|
||||
|
||||
def _email_hash(normalized_email: NormalizedEmail) -> str:
|
||||
return sha256(str(normalized_email).encode()).hexdigest()
|
||||
|
||||
|
||||
def challenge_to_record(challenge: OTPChallenge) -> HumanInputV2FormOTPChallenge:
|
||||
"""Map one detached aggregate into a record without plaintext secrets."""
|
||||
|
||||
contact_id: str | None = None
|
||||
if isinstance(challenge.subject, ContactOTPSubject):
|
||||
subject_type = HumanInputApproverGrantSubjectType.CONTACT
|
||||
contact_id = str(challenge.subject.contact_id)
|
||||
else:
|
||||
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
record = HumanInputV2FormOTPChallenge(
|
||||
tenant_id=str(challenge.ref.form_ref.workspace_id),
|
||||
form_id=str(challenge.ref.form_ref.form_id),
|
||||
approver_grant_id=str(challenge.ref.grant_ref.grant_id),
|
||||
subject_type=subject_type,
|
||||
contact_id=contact_id,
|
||||
challenge_token_hash=challenge.challenge_token_hash,
|
||||
code_hash=challenge.code_hash.encoded_value,
|
||||
code_hash_algorithm=challenge.code_hash.algorithm,
|
||||
email_hash=_email_hash(challenge.normalized_email),
|
||||
email=str(challenge.normalized_email),
|
||||
status=challenge.status,
|
||||
expires_at=challenge.expires_at.value,
|
||||
resend_after=challenge.resend_after.value,
|
||||
send_count=challenge.send_count,
|
||||
attempt_count=challenge.attempt_count,
|
||||
verified_at=challenge.verified_at.value if challenge.verified_at is not None else None,
|
||||
invalidated_at=challenge.invalidated_at.value if challenge.invalidated_at is not None else None,
|
||||
)
|
||||
record.id = str(challenge.ref.challenge_id)
|
||||
record.created_at = challenge.created_at.value
|
||||
record.updated_at = challenge.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def challenge_from_record(record: HumanInputV2FormOTPChallenge) -> OTPChallenge:
|
||||
"""Rebuild one aggregate while rejecting malformed or secret-unsafe rows."""
|
||||
|
||||
try:
|
||||
normalized_email = NormalizedEmail(record.email)
|
||||
except ValueError as error:
|
||||
raise ValueError("OTP challenge record has an invalid email") from error
|
||||
if record.email_hash != _email_hash(normalized_email):
|
||||
raise ValueError("OTP challenge record email hash does not match its normalized email")
|
||||
try:
|
||||
code_hash = OTPCodeHash(record.code_hash, record.code_hash_algorithm)
|
||||
except ValueError as error:
|
||||
raise ValueError("OTP challenge record has invalid code hash metadata") from error
|
||||
|
||||
subject: EmailOTPSubject
|
||||
if record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
|
||||
if record.contact_id is None:
|
||||
raise ValueError("contact OTP challenge record is missing contact_id")
|
||||
subject = ContactOTPSubject(ContactId(record.contact_id))
|
||||
elif record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
|
||||
if record.contact_id is not None:
|
||||
raise ValueError("email-address OTP challenge record must not contain contact_id")
|
||||
subject = EmailAddressOTPSubject(normalized_email)
|
||||
else:
|
||||
raise ValueError("OTP challenge record has an unsupported subject type")
|
||||
if not isinstance(record.status, HumanInputOTPChallengeStatus):
|
||||
raise ValueError("OTP challenge record has an unsupported status")
|
||||
|
||||
challenge_ref = (
|
||||
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
|
||||
.grant(ApproverGrantId(record.approver_grant_id))
|
||||
.challenge(OTPChallengeId(record.id))
|
||||
)
|
||||
return OTPChallenge(
|
||||
ref=challenge_ref,
|
||||
subject=subject,
|
||||
normalized_email=normalized_email,
|
||||
challenge_token_hash=record.challenge_token_hash,
|
||||
code_hash=code_hash,
|
||||
status=record.status,
|
||||
expires_at=_timestamp(record.expires_at),
|
||||
resend_after=_timestamp(record.resend_after),
|
||||
send_count=record.send_count,
|
||||
attempt_count=record.attempt_count,
|
||||
verified_at=_timestamp(record.verified_at) if record.verified_at is not None else None,
|
||||
invalidated_at=_timestamp(record.invalidated_at) if record.invalidated_at is not None else None,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def proof_to_record_value(proof: VerifiedEmailOTPProof) -> EmailOTPAuthorizationProof:
|
||||
"""Serialize one verified proof without code, token, or hash material."""
|
||||
|
||||
contact_id: str | None = None
|
||||
if isinstance(proof.subject, ContactOTPSubject):
|
||||
subject_type = HumanInputApproverGrantSubjectType.CONTACT
|
||||
contact_id = str(proof.subject.contact_id)
|
||||
else:
|
||||
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
return EmailOTPAuthorizationProof(
|
||||
otp_challenge_id=str(proof.challenge_ref.challenge_id),
|
||||
workspace_id=str(proof.challenge_ref.form_ref.workspace_id),
|
||||
form_id=str(proof.challenge_ref.form_ref.form_id),
|
||||
approver_grant_id=str(proof.challenge_ref.grant_ref.grant_id),
|
||||
subject_type=subject_type,
|
||||
contact_id=contact_id,
|
||||
verified_email=str(proof.normalized_email),
|
||||
verified_at=proof.verified_at.value,
|
||||
)
|
||||
|
||||
|
||||
def proof_from_record_value(
|
||||
record_value: EmailOTPAuthorizationProof,
|
||||
*,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> VerifiedEmailOTPProof:
|
||||
"""Rebuild one proof value after validating its captured subject shape."""
|
||||
|
||||
if record_value.workspace_id != str(workspace_id):
|
||||
raise ValueError("OTP proof owner does not match the requested workspace")
|
||||
normalized_email = NormalizedEmail(record_value.verified_email)
|
||||
subject: EmailOTPSubject
|
||||
if record_value.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
|
||||
if record_value.contact_id is None:
|
||||
raise ValueError("contact OTP proof is missing contact_id")
|
||||
subject = ContactOTPSubject(ContactId(record_value.contact_id))
|
||||
elif record_value.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
|
||||
if record_value.contact_id is not None:
|
||||
raise ValueError("email-address OTP proof must not contain contact_id")
|
||||
subject = EmailAddressOTPSubject(normalized_email)
|
||||
else:
|
||||
raise ValueError("OTP proof has an unsupported subject type")
|
||||
challenge_ref = (
|
||||
FormRef(workspace_id, FormId(record_value.form_id))
|
||||
.grant(ApproverGrantId(record_value.approver_grant_id))
|
||||
.challenge(OTPChallengeId(record_value.otp_challenge_id))
|
||||
)
|
||||
return VerifiedEmailOTPProof(
|
||||
challenge_ref=challenge_ref,
|
||||
subject=subject,
|
||||
normalized_email=normalized_email,
|
||||
verified_at=_timestamp(record_value.verified_at),
|
||||
)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Grant-locked SQLAlchemy adapter for Email OTP proof sessions.
|
||||
|
||||
Every write locks the stable approver-grant row with its complete owner chain.
|
||||
Replacement hashing completes before tracked rows are mutated; invalidation,
|
||||
replacement insertion, and an injected audit append then share one short
|
||||
transaction. The Submission Runtime persistence layer owns the concrete shared
|
||||
form-audit writer and table; this module owns only the transaction-scoped port.
|
||||
An elapsed pending challenge is committed as expired without hashing, audit, or
|
||||
replacement side effects. The adapter never loads or writes Form lifecycle fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ApproverGrantRef,
|
||||
Clock,
|
||||
ContactOTPSubject,
|
||||
CurrentEmailOTPIdentity,
|
||||
EmailAddressOTPSubject,
|
||||
OTPChallenge,
|
||||
OTPChallengeRef,
|
||||
OTPChallengeRejectionReason,
|
||||
OTPCodeHasher,
|
||||
OTPReplacementDecision,
|
||||
OTPVerificationDecision,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType
|
||||
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormOTPChallenge,
|
||||
)
|
||||
|
||||
from .mappers import challenge_from_record, challenge_to_record
|
||||
|
||||
|
||||
class OTPPersistenceError(RuntimeError):
|
||||
"""An OTP write failed before its complete transaction could commit."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OTPChallengeAuditFact:
|
||||
"""Secret-free issuance fact appended in the replacement transaction."""
|
||||
|
||||
audit_event_id: str
|
||||
challenge_ref: OTPChallengeRef
|
||||
previous_challenge_id: OTPChallengeId | None
|
||||
send_count: int
|
||||
occurred_at: UtcTimestamp
|
||||
|
||||
|
||||
class OTPChallengeAuditWriter(Protocol):
|
||||
"""Append one audit fact through the caller-owned SQLAlchemy transaction.
|
||||
|
||||
The concrete shared audit mapping belongs to Submission Runtime persistence.
|
||||
"""
|
||||
|
||||
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
|
||||
"""Add the fact without committing, flushing, or retaining the session."""
|
||||
|
||||
...
|
||||
|
||||
|
||||
class SQLAlchemyOTPChallengeRepository:
|
||||
"""Operation-oriented OTP adapter serialized by one grant row lock."""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
_clock: Clock
|
||||
_code_hasher: OTPCodeHasher
|
||||
_audit_writer: OTPChallengeAuditWriter
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_maker: sessionmaker[Session],
|
||||
*,
|
||||
clock: Clock,
|
||||
code_hasher: OTPCodeHasher,
|
||||
audit_writer: OTPChallengeAuditWriter,
|
||||
) -> None:
|
||||
self._session_maker = session_maker
|
||||
self._clock = clock
|
||||
self._code_hasher = code_hasher
|
||||
self._audit_writer = audit_writer
|
||||
|
||||
def issue_initial(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPChallenge:
|
||||
"""Issue the first challenge for a grant scope and append its audit fact."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
grant_record = self._lock_grant(session, grant_ref)
|
||||
if self._load_latest_record(session, grant_ref) is not None:
|
||||
raise ValueError("OTP challenge already exists for the grant scope")
|
||||
current_identity = self._current_identity(session, grant_record, grant_ref)
|
||||
if current_identity.subject is None or current_identity.normalized_email is None:
|
||||
raise ValueError("grant scope has no current Email identity")
|
||||
challenge = OTPChallenge.issue(
|
||||
challenge_ref=grant_ref.challenge(challenge_id),
|
||||
subject=current_identity.subject,
|
||||
normalized_email=current_identity.normalized_email,
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
plaintext_code=plaintext_code,
|
||||
send_count=1,
|
||||
clock=self._clock,
|
||||
code_hasher=self._code_hasher,
|
||||
)
|
||||
session.add(challenge_to_record(challenge))
|
||||
self._append_audit(
|
||||
session,
|
||||
challenge=challenge,
|
||||
previous_challenge_id=None,
|
||||
audit_event_id=audit_event_id,
|
||||
)
|
||||
session.flush()
|
||||
return challenge
|
||||
except SQLAlchemyError as error:
|
||||
raise OTPPersistenceError("failed to issue initial OTP challenge") from error
|
||||
|
||||
def replace_current(
|
||||
self,
|
||||
grant_ref: ApproverGrantRef,
|
||||
*,
|
||||
challenge_id: OTPChallengeId,
|
||||
audit_event_id: str,
|
||||
challenge_token_hash: str,
|
||||
plaintext_code: str,
|
||||
) -> OTPReplacementDecision:
|
||||
"""Atomically expire or replace the latest challenge after policy checks."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
grant_record = self._lock_grant(session, grant_ref)
|
||||
current_record = self._load_latest_record(session, grant_ref)
|
||||
if current_record is None:
|
||||
raise ValueError("current OTP challenge does not exist for the grant scope")
|
||||
current = challenge_from_record(current_record)
|
||||
current_identity = self._current_identity(session, grant_record, grant_ref)
|
||||
if not self._identity_matches(current, current_identity):
|
||||
invalidated = current.invalidate(clock=self._clock)
|
||||
self._apply_state(current_record, invalidated)
|
||||
session.flush()
|
||||
return OTPReplacementDecision(
|
||||
invalidated,
|
||||
None,
|
||||
OTPChallengeRejectionReason.STALE_IDENTITY,
|
||||
)
|
||||
decision = current.replace(
|
||||
challenge_ref=grant_ref.challenge(challenge_id),
|
||||
challenge_token_hash=challenge_token_hash,
|
||||
plaintext_code=plaintext_code,
|
||||
clock=self._clock,
|
||||
code_hasher=self._code_hasher,
|
||||
)
|
||||
if decision.replacement is None:
|
||||
if decision.previous != current:
|
||||
self._apply_state(current_record, decision.previous)
|
||||
session.flush()
|
||||
return decision
|
||||
self._apply_state(current_record, decision.previous)
|
||||
session.add(challenge_to_record(decision.replacement))
|
||||
self._append_audit(
|
||||
session,
|
||||
challenge=decision.replacement,
|
||||
previous_challenge_id=current.ref.challenge_id,
|
||||
audit_event_id=audit_event_id,
|
||||
)
|
||||
session.flush()
|
||||
return decision
|
||||
except SQLAlchemyError as error:
|
||||
raise OTPPersistenceError("failed to replace current OTP challenge") from error
|
||||
|
||||
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision:
|
||||
"""Verify one current challenge under its grant lock and persist counters."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
grant_record = self._lock_grant(session, challenge_ref.grant_ref)
|
||||
record = self._load_record(session, challenge_ref)
|
||||
if record is None:
|
||||
raise ValueError("OTP challenge does not exist in the requested owner scope")
|
||||
challenge = challenge_from_record(record)
|
||||
current_identity = self._current_identity(session, grant_record, challenge_ref.grant_ref)
|
||||
if not self._identity_matches(challenge, current_identity):
|
||||
invalidated = challenge.invalidate(clock=self._clock)
|
||||
self._apply_state(record, invalidated)
|
||||
session.flush()
|
||||
return OTPVerificationDecision(
|
||||
invalidated,
|
||||
None,
|
||||
OTPChallengeRejectionReason.STALE_IDENTITY,
|
||||
)
|
||||
decision = challenge.verify(
|
||||
plaintext_code=plaintext_code,
|
||||
clock=self._clock,
|
||||
code_hasher=self._code_hasher,
|
||||
)
|
||||
self._apply_state(record, decision.challenge)
|
||||
session.flush()
|
||||
return decision
|
||||
except SQLAlchemyError as error:
|
||||
raise OTPPersistenceError("failed to verify OTP challenge") from error
|
||||
|
||||
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None:
|
||||
"""Invalidate the latest challenge in one grant scope without Form mutation."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
self._lock_grant(session, grant_ref)
|
||||
record = self._load_latest_record(session, grant_ref)
|
||||
if record is None:
|
||||
return None
|
||||
invalidated = challenge_from_record(record).invalidate(clock=self._clock)
|
||||
self._apply_state(record, invalidated)
|
||||
session.flush()
|
||||
return invalidated
|
||||
except SQLAlchemyError as error:
|
||||
raise OTPPersistenceError("failed to invalidate current OTP challenge") from error
|
||||
|
||||
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None:
|
||||
"""Load one challenge only when every owner predicate matches."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
record = self._load_record(session, challenge_ref)
|
||||
return challenge_from_record(record) if record is not None else None
|
||||
except SQLAlchemyError as error:
|
||||
raise OTPPersistenceError("failed to load OTP challenge") from error
|
||||
|
||||
@staticmethod
|
||||
def _locked_grant_statement(grant_ref: ApproverGrantRef) -> sa.Select[tuple[HumanInputV2FormApproverGrant]]:
|
||||
return (
|
||||
select(HumanInputV2FormApproverGrant)
|
||||
.where(
|
||||
HumanInputV2FormApproverGrant.tenant_id == str(grant_ref.form_ref.workspace_id),
|
||||
HumanInputV2FormApproverGrant.form_id == str(grant_ref.form_ref.form_id),
|
||||
HumanInputV2FormApproverGrant.id == str(grant_ref.grant_id),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
def _lock_grant(self, session: Session, grant_ref: ApproverGrantRef) -> HumanInputV2FormApproverGrant:
|
||||
grant_record = session.scalar(self._locked_grant_statement(grant_ref))
|
||||
if grant_record is None:
|
||||
raise ValueError("approver grant scope does not exist")
|
||||
return grant_record
|
||||
|
||||
@staticmethod
|
||||
def _load_latest_record(
|
||||
session: Session,
|
||||
grant_ref: ApproverGrantRef,
|
||||
) -> HumanInputV2FormOTPChallenge | None:
|
||||
return session.scalar(
|
||||
select(HumanInputV2FormOTPChallenge)
|
||||
.where(
|
||||
HumanInputV2FormOTPChallenge.tenant_id == str(grant_ref.form_ref.workspace_id),
|
||||
HumanInputV2FormOTPChallenge.form_id == str(grant_ref.form_ref.form_id),
|
||||
HumanInputV2FormOTPChallenge.approver_grant_id == str(grant_ref.grant_id),
|
||||
)
|
||||
.order_by(HumanInputV2FormOTPChallenge.created_at.desc(), HumanInputV2FormOTPChallenge.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_record(session: Session, challenge_ref: OTPChallengeRef) -> HumanInputV2FormOTPChallenge | None:
|
||||
return session.scalar(
|
||||
select(HumanInputV2FormOTPChallenge).where(
|
||||
HumanInputV2FormOTPChallenge.tenant_id == str(challenge_ref.form_ref.workspace_id),
|
||||
HumanInputV2FormOTPChallenge.form_id == str(challenge_ref.form_ref.form_id),
|
||||
HumanInputV2FormOTPChallenge.approver_grant_id == str(challenge_ref.grant_ref.grant_id),
|
||||
HumanInputV2FormOTPChallenge.id == str(challenge_ref.challenge_id),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _current_identity(
|
||||
session: Session,
|
||||
grant_record: HumanInputV2FormApproverGrant,
|
||||
grant_ref: ApproverGrantRef,
|
||||
) -> CurrentEmailOTPIdentity:
|
||||
if grant_record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
|
||||
if grant_record.contact_id is None:
|
||||
raise ValueError("contact grant is missing contact_id")
|
||||
contact = session.scalar(
|
||||
select(HumanInputContact).where(
|
||||
HumanInputContact.id == grant_record.contact_id,
|
||||
sa.or_(
|
||||
HumanInputContact.tenant_id == str(grant_ref.form_ref.workspace_id),
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
if contact is None or contact.normalized_email is None:
|
||||
return CurrentEmailOTPIdentity(grant_ref, None, None)
|
||||
return CurrentEmailOTPIdentity(
|
||||
grant_ref,
|
||||
ContactOTPSubject(contact_id=ContactId(contact.id)),
|
||||
NormalizedEmail(contact.normalized_email),
|
||||
)
|
||||
if grant_record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
|
||||
if grant_record.normalized_email is None:
|
||||
raise ValueError("email-address grant is missing normalized_email")
|
||||
normalized_email = NormalizedEmail(grant_record.normalized_email)
|
||||
return CurrentEmailOTPIdentity(
|
||||
grant_ref,
|
||||
EmailAddressOTPSubject(normalized_email),
|
||||
normalized_email,
|
||||
)
|
||||
raise ValueError("approver grant subject does not support Email OTP")
|
||||
|
||||
@staticmethod
|
||||
def _identity_matches(challenge: OTPChallenge, current_identity: CurrentEmailOTPIdentity) -> bool:
|
||||
return (
|
||||
current_identity.subject is not None
|
||||
and current_identity.normalized_email is not None
|
||||
and challenge.subject == current_identity.subject
|
||||
and challenge.normalized_email == current_identity.normalized_email
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_state(record: HumanInputV2FormOTPChallenge, challenge: OTPChallenge) -> None:
|
||||
record.status = challenge.status
|
||||
record.send_count = challenge.send_count
|
||||
record.attempt_count = challenge.attempt_count
|
||||
record.verified_at = challenge.verified_at.value if challenge.verified_at is not None else None
|
||||
record.invalidated_at = challenge.invalidated_at.value if challenge.invalidated_at is not None else None
|
||||
record.updated_at = challenge.updated_at.value
|
||||
|
||||
def _append_audit(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
challenge: OTPChallenge,
|
||||
previous_challenge_id: OTPChallengeId | None,
|
||||
audit_event_id: str,
|
||||
) -> None:
|
||||
self._audit_writer.append(
|
||||
session,
|
||||
OTPChallengeAuditFact(
|
||||
audit_event_id=audit_event_id,
|
||||
challenge_ref=challenge.ref,
|
||||
previous_challenge_id=previous_challenge_id,
|
||||
send_count=challenge.send_count,
|
||||
occurred_at=challenge.created_at,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""SQLAlchemy mapping and transactions for the Contact Directory aggregate.
|
||||
|
||||
This package may depend on domain and ORM modules. It hides records, sessions,
|
||||
owner predicates, locking, and rollback from domain and application callers.
|
||||
"""
|
||||
|
||||
from .repository import SQLAlchemyContactDirectoryRepository
|
||||
|
||||
__all__ = ["SQLAlchemyContactDirectoryRepository"]
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Explicit mapping between Contact Directory values and persistence records."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactIdentitySource,
|
||||
ContactOwner,
|
||||
ExternalContactOwner,
|
||||
OrganizationAccountOwner,
|
||||
PlatformWorkspaceEntry,
|
||||
WorkspaceMemberOwner,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
NormalizedEmail,
|
||||
PlatformEntryId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
)
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> UtcTimestamp:
|
||||
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
|
||||
|
||||
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
|
||||
|
||||
|
||||
def contact_from_record(record: HumanInputContact) -> Contact:
|
||||
"""Map one persistence record into an infrastructure-free Contact."""
|
||||
|
||||
identity_source = ContactIdentitySource(record.identity_source.value)
|
||||
owner: ContactOwner
|
||||
match identity_source:
|
||||
case ContactIdentitySource.ORGANIZATION_ACCOUNT:
|
||||
if record.account_id is None:
|
||||
raise ValueError("organization account record is missing account_id")
|
||||
owner = OrganizationAccountOwner(AccountId(record.account_id))
|
||||
case ContactIdentitySource.WORKSPACE_MEMBER:
|
||||
if record.tenant_id is None or record.account_id is None:
|
||||
raise ValueError("workspace member record is missing owner fields")
|
||||
owner = WorkspaceMemberOwner(WorkspaceId(record.tenant_id), AccountId(record.account_id))
|
||||
case ContactIdentitySource.EXTERNAL:
|
||||
if record.tenant_id is None:
|
||||
raise ValueError("external contact record is missing tenant_id")
|
||||
owner = ExternalContactOwner(WorkspaceId(record.tenant_id))
|
||||
|
||||
return Contact(
|
||||
id=ContactId(record.id),
|
||||
identity_source=identity_source,
|
||||
owner=owner,
|
||||
name=record.name,
|
||||
normalized_name=record.normalized_name,
|
||||
email=record.email,
|
||||
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
|
||||
avatar_file_id=record.avatar_file_id,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def contact_to_record(contact: Contact) -> HumanInputContact:
|
||||
"""Map one Contact into a detached persistence record."""
|
||||
|
||||
tenant_id: str | None
|
||||
account_id: str | None
|
||||
match contact.owner:
|
||||
case OrganizationAccountOwner(account_id=owner_account_id):
|
||||
tenant_id = None
|
||||
account_id = str(owner_account_id)
|
||||
case WorkspaceMemberOwner(workspace_id=workspace_id, account_id=owner_account_id):
|
||||
tenant_id = str(workspace_id)
|
||||
account_id = str(owner_account_id)
|
||||
case ExternalContactOwner(workspace_id=workspace_id):
|
||||
tenant_id = str(workspace_id)
|
||||
account_id = None
|
||||
|
||||
record = HumanInputContact(
|
||||
name=contact.name,
|
||||
normalized_name=contact.normalized_name,
|
||||
identity_source=HumanInputContactIdentitySource(contact.identity_source.value),
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
email=contact.email,
|
||||
normalized_email=str(contact.normalized_email) if contact.normalized_email is not None else None,
|
||||
avatar_file_id=contact.avatar_file_id,
|
||||
)
|
||||
record.id = str(contact.id)
|
||||
record.created_at = contact.created_at.value
|
||||
record.updated_at = contact.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def platform_entry_from_record(record: HumanInputPlatformContactWorkspaceEntry) -> PlatformWorkspaceEntry:
|
||||
"""Map one Platform allow-list record into a domain fact."""
|
||||
|
||||
return PlatformWorkspaceEntry(
|
||||
id=PlatformEntryId(record.id),
|
||||
workspace_id=WorkspaceId(record.tenant_id),
|
||||
contact_id=ContactId(record.contact_id),
|
||||
added_by_account_id=AccountId(record.added_by_account_id),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def platform_entry_to_record(entry: PlatformWorkspaceEntry) -> HumanInputPlatformContactWorkspaceEntry:
|
||||
"""Map one Platform allow-list fact into a detached persistence record."""
|
||||
|
||||
record = HumanInputPlatformContactWorkspaceEntry(
|
||||
tenant_id=str(entry.workspace_id),
|
||||
contact_id=str(entry.contact_id),
|
||||
added_by_account_id=str(entry.added_by_account_id),
|
||||
)
|
||||
record.id = str(entry.id)
|
||||
record.created_at = entry.created_at.value
|
||||
record.updated_at = entry.updated_at.value
|
||||
return record
|
||||
@@ -0,0 +1,419 @@
|
||||
"""SQLAlchemy Contact Directory adapter with aggregate-scoped transactions.
|
||||
|
||||
Every consuming query includes the complete deployment/workspace owner
|
||||
predicate. EE Organization writes require the stable ``DifySetup`` row;
|
||||
External writes share that lock when the deployment owner exists. ORM instances
|
||||
never cross this boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, selectinload, sessionmaker
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactDirectoryPolicy,
|
||||
ContactDirectorySnapshot,
|
||||
ContactRejectionCode,
|
||||
ExternalContactOwner,
|
||||
OrganizationAccountOwner,
|
||||
PlatformWorkspaceEntry,
|
||||
WorkspaceMemberOwner,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, ContactId, PlatformEntryId, UtcTimestamp, WorkspaceId
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.account import Account, AccountStatus, TenantAccountJoin
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
)
|
||||
from models.model import DifySetup
|
||||
|
||||
from .mappers import contact_from_record, contact_to_record, platform_entry_to_record
|
||||
|
||||
|
||||
class SQLAlchemyContactDirectoryRepository:
|
||||
"""Transactional adapter for coherent Contact Directory operations."""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
||||
self._session_maker = session_maker
|
||||
|
||||
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
|
||||
"""Load one coherent contacts, membership, allow-list, and Account view."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
self._configure_snapshot_transaction(session)
|
||||
return self._load_snapshot(session, workspace_id)
|
||||
except ContactDirectoryError:
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
raise self._persistence_error() from error
|
||||
|
||||
def save_organization_contact(self, contact: Contact) -> Contact:
|
||||
"""Persist one deployment-owned Organization Contact after a serialized identity claim."""
|
||||
|
||||
if not isinstance(contact.owner, OrganizationAccountOwner):
|
||||
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
|
||||
return self._save_account_backed_contact(contact, workspace_owner=None)
|
||||
|
||||
def save_workspace_member_contact(self, contact: Contact) -> Contact:
|
||||
"""Persist one Contact only while its owning workspace membership exists."""
|
||||
|
||||
if not isinstance(contact.owner, WorkspaceMemberOwner):
|
||||
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
|
||||
return self._save_account_backed_contact(contact, workspace_owner=contact.owner)
|
||||
|
||||
def _save_account_backed_contact(
|
||||
self,
|
||||
contact: Contact,
|
||||
*,
|
||||
workspace_owner: WorkspaceMemberOwner | None,
|
||||
) -> Contact:
|
||||
"""Persist an already source-validated Account-backed Contact."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
if workspace_owner is None:
|
||||
self._lock_deployment_owner(session, require_setup_row=True)
|
||||
else:
|
||||
self._ensure_workspace_membership(session, workspace_owner)
|
||||
self._ensure_account_available(session, contact)
|
||||
self._ensure_identity_available(session, contact)
|
||||
record = self._find_owned_record(session, contact)
|
||||
if record is None:
|
||||
record = contact_to_record(contact)
|
||||
session.add(record)
|
||||
else:
|
||||
if not self._record_has_same_identity(record, contact):
|
||||
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
|
||||
self._copy_mutable_values(record, contact)
|
||||
session.flush()
|
||||
return contact_from_record(record)
|
||||
except ContactDirectoryError:
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
|
||||
except SQLAlchemyError as error:
|
||||
raise self._persistence_error() from error
|
||||
|
||||
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
|
||||
"""Atomically validate and create a new External Contact.
|
||||
|
||||
External and Organization Email claims share the deployment lock when
|
||||
deployment-wide Organization semantics exist. The lock is acquired
|
||||
before the first Contact read so a waiter observes the transaction that
|
||||
won the claim instead of retaining an older snapshot. SaaS deployments
|
||||
without a setup row retain tenant-scoped External identity semantics.
|
||||
"""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
self._lock_deployment_owner(session, require_setup_row=False)
|
||||
snapshot = self._load_snapshot(session, workspace_id)
|
||||
contact = ContactDirectoryPolicy.admit_external(
|
||||
snapshot,
|
||||
contact_id=ContactId(str(uuidv7())),
|
||||
name=name,
|
||||
email=email,
|
||||
now=UtcTimestamp(datetime.now(UTC)),
|
||||
)
|
||||
self._ensure_identity_available(session, contact)
|
||||
record = contact_to_record(contact)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return contact_from_record(record)
|
||||
except ContactDirectoryError:
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
|
||||
except SQLAlchemyError as error:
|
||||
raise self._persistence_error() from error
|
||||
|
||||
def set_platform_availability(
|
||||
self,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
*,
|
||||
added_by_account_id: AccountId,
|
||||
enabled: bool,
|
||||
) -> None:
|
||||
"""Idempotently mutate one workspace allow-list entry in a transaction."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
contact_record = session.scalar(
|
||||
select(HumanInputContact).where(
|
||||
HumanInputContact.id == str(contact_id),
|
||||
or_(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.tenant_id == str(workspace_id),
|
||||
),
|
||||
)
|
||||
)
|
||||
if contact_record is None:
|
||||
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
|
||||
if (
|
||||
contact_record.identity_source is not HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT
|
||||
or contact_record.tenant_id is not None
|
||||
):
|
||||
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
|
||||
|
||||
if enabled:
|
||||
now = UtcTimestamp(datetime.now(UTC))
|
||||
self._insert_platform_entry_idempotently(
|
||||
session,
|
||||
PlatformWorkspaceEntry(
|
||||
id=PlatformEntryId(str(uuidv7())),
|
||||
workspace_id=workspace_id,
|
||||
contact_id=contact_id,
|
||||
added_by_account_id=added_by_account_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
),
|
||||
)
|
||||
else:
|
||||
session.execute(
|
||||
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
|
||||
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id),
|
||||
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact_id),
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
except ContactDirectoryError:
|
||||
raise
|
||||
except IntegrityError as error:
|
||||
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
|
||||
except SQLAlchemyError as error:
|
||||
raise self._persistence_error() from error
|
||||
|
||||
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
|
||||
"""Hard-delete an External Contact only within its owning workspace."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
record = session.scalar(
|
||||
select(HumanInputContact).where(
|
||||
HumanInputContact.id == str(contact_id),
|
||||
HumanInputContact.tenant_id == str(workspace_id),
|
||||
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
|
||||
session.delete(record)
|
||||
except ContactDirectoryError:
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
raise self._persistence_error() from error
|
||||
|
||||
def _load_snapshot(self, session: Session, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
|
||||
contact_records = session.scalars(
|
||||
select(HumanInputContact)
|
||||
.options(
|
||||
selectinload(
|
||||
HumanInputContact.platform_workspace_entries.and_(
|
||||
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or_(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.tenant_id == str(workspace_id),
|
||||
)
|
||||
)
|
||||
.order_by(HumanInputContact.id)
|
||||
).all()
|
||||
contacts = tuple(contact_from_record(record) for record in contact_records)
|
||||
member_account_ids = frozenset(
|
||||
AccountId(account_id)
|
||||
for account_id in session.scalars(
|
||||
select(TenantAccountJoin.account_id).where(TenantAccountJoin.tenant_id == str(workspace_id))
|
||||
).all()
|
||||
)
|
||||
contact_account_ids = {contact.account_id for contact in contacts if contact.account_id is not None}
|
||||
active_account_ids = frozenset(
|
||||
AccountId(account_id)
|
||||
for account_id in session.scalars(
|
||||
select(Account.id).where(
|
||||
Account.id.in_([str(account_id) for account_id in contact_account_ids]),
|
||||
Account.status == AccountStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
platform_contact_ids = frozenset(
|
||||
ContactId(record.id) for record in contact_records if record.platform_workspace_entries
|
||||
)
|
||||
return ContactDirectorySnapshot(
|
||||
workspace_id=workspace_id,
|
||||
contacts=contacts,
|
||||
member_account_ids=member_account_ids,
|
||||
platform_contact_ids=platform_contact_ids,
|
||||
unavailable_account_ids=frozenset(contact_account_ids - active_account_ids),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _lock_deployment_owner(session: Session, *, require_setup_row: bool) -> None:
|
||||
"""Lock the deployment identity owner when its semantics apply.
|
||||
|
||||
Organization writes require this owner. External writes use it when
|
||||
present, while SaaS deployments without a deployment Organization keep
|
||||
their existing tenant-local admission boundary.
|
||||
"""
|
||||
|
||||
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
|
||||
if setup_version is None and require_setup_row:
|
||||
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.SETUP_ROW_MISSING)
|
||||
|
||||
@staticmethod
|
||||
def _configure_snapshot_transaction(session: Session) -> None:
|
||||
"""Use one MVCC snapshot for all facts loaded by the directory query."""
|
||||
|
||||
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
|
||||
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
|
||||
|
||||
@staticmethod
|
||||
def _ensure_workspace_membership(session: Session, owner: WorkspaceMemberOwner) -> None:
|
||||
membership_id = session.scalar(
|
||||
select(TenantAccountJoin.id)
|
||||
.where(
|
||||
TenantAccountJoin.tenant_id == str(owner.workspace_id),
|
||||
TenantAccountJoin.account_id == str(owner.account_id),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if membership_id is None:
|
||||
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.INVALID_OWNER)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_account_available(session: Session, contact: Contact) -> None:
|
||||
account_id = contact.account_id
|
||||
if account_id is None:
|
||||
return
|
||||
status = session.scalar(select(Account.status).where(Account.id == str(account_id)))
|
||||
if status is not AccountStatus.ACTIVE:
|
||||
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.ACCOUNT_UNAVAILABLE)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_identity_available(session: Session, contact: Contact) -> None:
|
||||
identity_predicates: list[sa.ColumnElement[bool]] = []
|
||||
if contact.account_id is not None:
|
||||
identity_predicates.append(
|
||||
sa.and_(
|
||||
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
|
||||
HumanInputContact.account_id == str(contact.account_id),
|
||||
)
|
||||
)
|
||||
if contact.normalized_email is not None:
|
||||
identity_predicates.append(
|
||||
sa.and_(
|
||||
SQLAlchemyContactDirectoryRepository._email_identity_scope_predicate(contact),
|
||||
HumanInputContact.normalized_email == str(contact.normalized_email),
|
||||
)
|
||||
)
|
||||
if not identity_predicates:
|
||||
return
|
||||
conflict_id = session.scalar(
|
||||
select(HumanInputContact.id).where(
|
||||
HumanInputContact.id != str(contact.id),
|
||||
or_(*identity_predicates),
|
||||
)
|
||||
)
|
||||
if conflict_id is not None:
|
||||
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY)
|
||||
|
||||
@staticmethod
|
||||
def _email_identity_scope_predicate(contact: Contact) -> sa.ColumnElement[bool]:
|
||||
if isinstance(contact.owner, OrganizationAccountOwner):
|
||||
return or_(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
|
||||
)
|
||||
if isinstance(contact.owner, ExternalContactOwner):
|
||||
return or_(
|
||||
HumanInputContact.tenant_id == str(contact.owner.workspace_id),
|
||||
sa.and_(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.identity_source == HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
),
|
||||
)
|
||||
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
|
||||
|
||||
@staticmethod
|
||||
def _insert_platform_entry_idempotently(session: Session, entry: PlatformWorkspaceEntry) -> None:
|
||||
record = platform_entry_to_record(entry)
|
||||
values = {
|
||||
"id": record.id,
|
||||
"tenant_id": record.tenant_id,
|
||||
"contact_id": record.contact_id,
|
||||
"added_by_account_id": record.added_by_account_id,
|
||||
"created_at": record.created_at,
|
||||
"updated_at": record.updated_at,
|
||||
}
|
||||
dialect_name = session.get_bind().dialect.name
|
||||
if dialect_name == "postgresql":
|
||||
postgresql_statement = postgresql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
|
||||
session.execute(postgresql_statement.on_conflict_do_nothing(constraint="hipcwe_tenant_contact_uq"))
|
||||
return
|
||||
if dialect_name == "mysql":
|
||||
mysql_statement = mysql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
|
||||
session.execute(mysql_statement.on_duplicate_key_update(contact_id=mysql_statement.inserted.contact_id))
|
||||
return
|
||||
if dialect_name == "sqlite":
|
||||
sqlite_statement = sqlite_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
|
||||
session.execute(sqlite_statement.on_conflict_do_nothing(index_elements=["tenant_id", "contact_id"]))
|
||||
return
|
||||
raise SQLAlchemyContactDirectoryRepository._persistence_error()
|
||||
|
||||
@staticmethod
|
||||
def _find_owned_record(session: Session, contact: Contact) -> HumanInputContact | None:
|
||||
return session.scalar(
|
||||
select(HumanInputContact).where(
|
||||
HumanInputContact.id == str(contact.id),
|
||||
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _owner_predicate(contact: Contact) -> sa.ColumnElement[bool]:
|
||||
if isinstance(contact.owner, OrganizationAccountOwner):
|
||||
return HumanInputContact.tenant_id.is_(None)
|
||||
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
|
||||
|
||||
@staticmethod
|
||||
def _record_has_same_identity(record: HumanInputContact, contact: Contact) -> bool:
|
||||
expected_source = HumanInputContactIdentitySource(contact.identity_source.value)
|
||||
expected_account_id = str(contact.account_id) if contact.account_id is not None else None
|
||||
return record.identity_source is expected_source and record.account_id == expected_account_id
|
||||
|
||||
@staticmethod
|
||||
def _copy_mutable_values(record: HumanInputContact, contact: Contact) -> None:
|
||||
record.name = contact.name
|
||||
record.normalized_name = contact.normalized_name
|
||||
record.email = contact.email
|
||||
record.normalized_email = str(contact.normalized_email) if contact.normalized_email is not None else None
|
||||
record.avatar_file_id = contact.avatar_file_id
|
||||
record.updated_at = contact.updated_at.value
|
||||
|
||||
@staticmethod
|
||||
def _domain_error(code: ContactRejectionCode) -> ContactDirectoryError:
|
||||
from core.human_input_v2.contact_directory import ContactRejection
|
||||
|
||||
return ContactDirectoryError(ContactRejection(code))
|
||||
|
||||
@staticmethod
|
||||
def _persistence_error() -> ContactDirectoryError:
|
||||
return SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.PERSISTENCE_FAILURE)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""SQLAlchemy persistence adapter for the Human Input v2 form aggregate."""
|
||||
|
||||
from .repository import FormPersistenceError, SQLAlchemyFormRepository
|
||||
|
||||
__all__ = ["FormPersistenceError", "SQLAlchemyFormRepository"]
|
||||
@@ -0,0 +1,567 @@
|
||||
"""Explicit mapping between Form Core domain values and ORM records.
|
||||
|
||||
Every structured JSON value is validated at this boundary. ORM records never
|
||||
escape into the form domain, and mutable primitive containers are created only
|
||||
for immediate Pydantic serialization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import assert_never
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ApprovalSubject,
|
||||
ApproverGrant,
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointConfiguration,
|
||||
ContactApprovalSubject,
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpoint,
|
||||
DeliveryEndpointConfiguration,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointConfiguration,
|
||||
EmailProviderConfiguration,
|
||||
EndpointAccessCapability,
|
||||
EndUserApprovalSubject,
|
||||
FormRef,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
IMEndpointConfiguration,
|
||||
MatchedRecipientSource,
|
||||
SubjectSnapshot,
|
||||
UploadCapability,
|
||||
UploadFileAssociation,
|
||||
WebEndpointConfiguration,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
ContactId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
EmailProviderId,
|
||||
EndUserId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
|
||||
from models.base import DefaultFieldsDCMixin
|
||||
from models.human_input_v2 import (
|
||||
FormApproverGrantMatchedSource,
|
||||
FormApproverGrantMatchedSources,
|
||||
FormApproverGrantSubjectSnapshot,
|
||||
FormDeliveryProviderResponse,
|
||||
HumanInputEmailProvider,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormDefinition,
|
||||
HumanInputV2FormDeliveryAttempt,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
HumanInputV2FormUploadFile,
|
||||
HumanInputV2FormUploadToken,
|
||||
ResendEmailProviderEncryptedCredentials,
|
||||
)
|
||||
|
||||
_FORM_INPUT_ADAPTER: TypeAdapter[FormInputConfig] = TypeAdapter(FormInputConfig)
|
||||
_JSON_OBJECT_ADAPTER: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> UtcTimestamp:
|
||||
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
|
||||
|
||||
|
||||
def _set_record_identity(
|
||||
record: DefaultFieldsDCMixin,
|
||||
*,
|
||||
record_id: str,
|
||||
created_at: UtcTimestamp,
|
||||
updated_at: UtcTimestamp,
|
||||
) -> None:
|
||||
# SQLAlchemy's mapped dataclass mixin owns these fields on every record type.
|
||||
record.id = record_id
|
||||
record.created_at = created_at.value
|
||||
record.updated_at = updated_at.value
|
||||
|
||||
|
||||
def _definition_to_record_value(definition: FrozenFormDefinition) -> HumanInputV2FormDefinition:
|
||||
inputs = tuple(_FORM_INPUT_ADAPTER.validate_python(item.to_mapping()) for item in definition.inputs)
|
||||
actions = tuple(
|
||||
UserActionConfig.model_validate(
|
||||
{"id": action.id, "title": action.title, "button_style": action.button_style},
|
||||
)
|
||||
for action in definition.actions
|
||||
)
|
||||
default_values = _JSON_OBJECT_ADAPTER.validate_python(definition.default_values.to_mapping())
|
||||
return HumanInputV2FormDefinition(
|
||||
form_content=definition.form_content,
|
||||
inputs=inputs,
|
||||
user_actions=actions,
|
||||
default_values=default_values,
|
||||
node_title=definition.node_title,
|
||||
display_in_ui=definition.display_in_ui,
|
||||
)
|
||||
|
||||
|
||||
def _definition_from_record_value(definition: HumanInputV2FormDefinition) -> FrozenFormDefinition:
|
||||
return FrozenFormDefinition(
|
||||
form_content=definition.form_content,
|
||||
inputs=tuple(
|
||||
FrozenJSONObject.from_mapping(_JSON_OBJECT_ADAPTER.validate_python(item.model_dump(mode="json")))
|
||||
for item in definition.inputs
|
||||
),
|
||||
actions=tuple(
|
||||
FrozenFormAction(
|
||||
id=action.id,
|
||||
title=action.title,
|
||||
button_style=action.button_style.value,
|
||||
)
|
||||
for action in definition.user_actions
|
||||
),
|
||||
default_values=FrozenJSONObject.from_mapping(definition.default_values),
|
||||
node_title=definition.node_title,
|
||||
display_in_ui=definition.display_in_ui,
|
||||
)
|
||||
|
||||
|
||||
def form_to_record(form: HumanInputForm) -> HumanInputV2Form:
|
||||
"""Map one form root into a detached record without child side effects."""
|
||||
|
||||
record = HumanInputV2Form(
|
||||
tenant_id=str(form.ref.workspace_id),
|
||||
app_id=str(form.app_id),
|
||||
form_definition=_definition_to_record_value(form.definition),
|
||||
rendered_content=form.rendered_content,
|
||||
node_timeout_at=form.node_timeout_at.value,
|
||||
global_expires_at=form.global_expires_at.value,
|
||||
form_kind=form.kind,
|
||||
status=form.status,
|
||||
workflow_pause_id=form.workflow_pause_id,
|
||||
node_execution_id=form.node_execution_id,
|
||||
)
|
||||
_set_record_identity(
|
||||
record,
|
||||
record_id=str(form.ref.form_id),
|
||||
created_at=form.created_at,
|
||||
updated_at=form.updated_at,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def form_from_record(
|
||||
record: HumanInputV2Form,
|
||||
grant_records: tuple[HumanInputV2FormApproverGrant, ...],
|
||||
) -> HumanInputForm:
|
||||
"""Rebuild one lifecycle-ready aggregate from an explicitly loaded grant graph."""
|
||||
|
||||
form_ref = FormRef(WorkspaceId(record.tenant_id), FormId(record.id))
|
||||
grants = tuple(grant_from_record(grant_record) for grant_record in grant_records)
|
||||
if any(grant.ref.form_ref != form_ref for grant in grants):
|
||||
raise ValueError("loaded grant does not belong to the form record")
|
||||
return HumanInputForm(
|
||||
ref=form_ref,
|
||||
app_id=AppId(record.app_id),
|
||||
definition=_definition_from_record_value(record.form_definition),
|
||||
rendered_content=record.rendered_content,
|
||||
node_timeout_at=_timestamp(record.node_timeout_at),
|
||||
global_expires_at=_timestamp(record.global_expires_at),
|
||||
kind=record.form_kind,
|
||||
status=record.status,
|
||||
workflow_pause_id=record.workflow_pause_id,
|
||||
node_execution_id=record.node_execution_id,
|
||||
grants=grants,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def grant_to_record(grant: ApproverGrant) -> HumanInputV2FormApproverGrant:
|
||||
"""Map one historical grant into a detached persistence record."""
|
||||
|
||||
contact_id: str | None = None
|
||||
end_user_id: str | None = None
|
||||
normalized_email: str | None = None
|
||||
match grant.subject:
|
||||
case ContactApprovalSubject(contact_id=subject_contact_id):
|
||||
subject_type = HumanInputApproverGrantSubjectType.CONTACT
|
||||
contact_id = str(subject_contact_id)
|
||||
case EndUserApprovalSubject(end_user_id=subject_end_user_id):
|
||||
subject_type = HumanInputApproverGrantSubjectType.END_USER
|
||||
end_user_id = str(subject_end_user_id)
|
||||
case EmailAddressApprovalSubject(normalized_email=subject_email):
|
||||
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
normalized_email = str(subject_email)
|
||||
case _:
|
||||
assert_never(grant.subject)
|
||||
record = HumanInputV2FormApproverGrant(
|
||||
tenant_id=str(grant.ref.form_ref.workspace_id),
|
||||
form_id=str(grant.ref.form_ref.form_id),
|
||||
subject_type=subject_type,
|
||||
subject_key=grant.subject_key.value,
|
||||
matched_sources=FormApproverGrantMatchedSources(
|
||||
sources=tuple(
|
||||
FormApproverGrantMatchedSource(
|
||||
kind=source.kind,
|
||||
position=source.position,
|
||||
reference=source.reference,
|
||||
)
|
||||
for source in grant.matched_sources
|
||||
)
|
||||
),
|
||||
subject_snapshot=FormApproverGrantSubjectSnapshot(
|
||||
display_name=grant.subject_snapshot.display_name,
|
||||
email=grant.subject_snapshot.email,
|
||||
),
|
||||
contact_id=contact_id,
|
||||
end_user_id=end_user_id,
|
||||
normalized_email=normalized_email,
|
||||
)
|
||||
_set_record_identity(record, record_id=str(grant.id), created_at=grant.created_at, updated_at=grant.updated_at)
|
||||
return record
|
||||
|
||||
|
||||
def grant_from_record(record: HumanInputV2FormApproverGrant) -> ApproverGrant:
|
||||
"""Map one validated grant record into a historical domain snapshot."""
|
||||
|
||||
subject: ApprovalSubject
|
||||
match record.subject_type:
|
||||
case HumanInputApproverGrantSubjectType.CONTACT:
|
||||
if record.contact_id is None:
|
||||
raise ValueError("contact grant record is missing contact_id")
|
||||
subject = ContactApprovalSubject(ContactId(record.contact_id))
|
||||
case HumanInputApproverGrantSubjectType.END_USER:
|
||||
if record.end_user_id is None:
|
||||
raise ValueError("end-user grant record is missing end_user_id")
|
||||
subject = EndUserApprovalSubject(EndUserId(record.end_user_id))
|
||||
case HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
|
||||
if record.normalized_email is None:
|
||||
raise ValueError("email grant record is missing normalized_email")
|
||||
subject = EmailAddressApprovalSubject(NormalizedEmail(record.normalized_email))
|
||||
return ApproverGrant(
|
||||
ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)).grant(ApproverGrantId(record.id)),
|
||||
subject=subject,
|
||||
subject_key=CanonicalSubjectKey(record.subject_key),
|
||||
matched_sources=tuple(
|
||||
MatchedRecipientSource(source.kind, source.position, source.reference)
|
||||
for source in record.matched_sources.sources
|
||||
),
|
||||
subject_snapshot=SubjectSnapshot(
|
||||
display_name=record.subject_snapshot.display_name,
|
||||
email=record.subject_snapshot.email,
|
||||
),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def endpoint_to_record(endpoint: DeliveryEndpoint) -> HumanInputV2FormDeliveryEndpoint:
|
||||
"""Map one discriminated endpoint configuration without raw dictionaries."""
|
||||
|
||||
email_address: str | None = None
|
||||
integration_id: str | None = None
|
||||
provider = None
|
||||
provider_tenant_id: str | None = None
|
||||
provider_user_id: str | None = None
|
||||
im_identity_id: str | None = None
|
||||
im_binding_id: str | None = None
|
||||
match endpoint.configuration:
|
||||
case EmailEndpointConfiguration(email_address=address):
|
||||
email_address = str(address)
|
||||
case IMEndpointConfiguration(
|
||||
integration_id=integration,
|
||||
provider=im_provider,
|
||||
provider_tenant_id=tenant_identity,
|
||||
identity_id=identity,
|
||||
binding_id=binding,
|
||||
provider_user_id=user_identity,
|
||||
):
|
||||
integration_id = str(integration)
|
||||
provider = im_provider
|
||||
provider_tenant_id = tenant_identity
|
||||
provider_user_id = user_identity
|
||||
im_identity_id = str(identity)
|
||||
im_binding_id = str(binding) if binding is not None else None
|
||||
case WebEndpointConfiguration() | ConsoleEndpointConfiguration():
|
||||
pass
|
||||
case _:
|
||||
assert_never(endpoint.configuration)
|
||||
record = HumanInputV2FormDeliveryEndpoint(
|
||||
tenant_id=str(endpoint.ref.form_ref.workspace_id),
|
||||
form_id=str(endpoint.ref.form_ref.form_id),
|
||||
approver_grant_id=str(endpoint.ref.grant_ref.grant_id),
|
||||
channel=endpoint.channel,
|
||||
address_hash=endpoint.address_hash,
|
||||
email_address=email_address,
|
||||
integration_id=integration_id,
|
||||
provider=provider,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
provider_user_id=provider_user_id,
|
||||
im_identity_id=im_identity_id,
|
||||
im_binding_id=im_binding_id,
|
||||
access_token_hash=endpoint.access_capability.token_hash if endpoint.access_capability is not None else None,
|
||||
)
|
||||
_set_record_identity(
|
||||
record, record_id=str(endpoint.id), created_at=endpoint.created_at, updated_at=endpoint.updated_at
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def endpoint_from_record(record: HumanInputV2FormDeliveryEndpoint) -> DeliveryEndpoint:
|
||||
"""Reject malformed discriminated endpoint records instead of leaking nulls."""
|
||||
|
||||
endpoint_ref = (
|
||||
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
|
||||
.grant(ApproverGrantId(record.approver_grant_id))
|
||||
.endpoint(DeliveryEndpointId(record.id))
|
||||
)
|
||||
configuration: DeliveryEndpointConfiguration
|
||||
match record.channel:
|
||||
case HumanInputDeliveryChannel.EMAIL:
|
||||
if record.email_address is None:
|
||||
raise ValueError("email endpoint record is missing email_address")
|
||||
if (
|
||||
record.integration_id is not None
|
||||
or record.provider is not None
|
||||
or record.provider_tenant_id is not None
|
||||
or record.provider_user_id is not None
|
||||
or record.im_identity_id is not None
|
||||
or record.im_binding_id is not None
|
||||
):
|
||||
raise ValueError("email endpoint record contains invalid channel configuration")
|
||||
configuration = EmailEndpointConfiguration(NormalizedEmail(record.email_address))
|
||||
case HumanInputDeliveryChannel.IM:
|
||||
if record.email_address is not None:
|
||||
raise ValueError("IM endpoint record contains invalid channel configuration")
|
||||
if (
|
||||
record.integration_id is None
|
||||
or record.provider is None
|
||||
or record.provider_tenant_id is None
|
||||
or record.im_identity_id is None
|
||||
or record.provider_user_id is None
|
||||
):
|
||||
raise ValueError("IM endpoint record is missing provider configuration")
|
||||
configuration = IMEndpointConfiguration(
|
||||
integration_id=IntegrationId(record.integration_id),
|
||||
provider=record.provider,
|
||||
provider_tenant_id=record.provider_tenant_id,
|
||||
identity_id=IMIdentityId(record.im_identity_id),
|
||||
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
|
||||
provider_user_id=record.provider_user_id,
|
||||
)
|
||||
case HumanInputDeliveryChannel.WEB | HumanInputDeliveryChannel.CONSOLE:
|
||||
if (
|
||||
record.email_address is not None
|
||||
or record.integration_id is not None
|
||||
or record.provider is not None
|
||||
or record.provider_tenant_id is not None
|
||||
or record.provider_user_id is not None
|
||||
or record.im_identity_id is not None
|
||||
or record.im_binding_id is not None
|
||||
):
|
||||
raise ValueError(f"{record.channel.value} endpoint record contains invalid channel configuration")
|
||||
configuration = (
|
||||
WebEndpointConfiguration()
|
||||
if record.channel is HumanInputDeliveryChannel.WEB
|
||||
else ConsoleEndpointConfiguration()
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"unsupported delivery channel: {record.channel!r}")
|
||||
capability = (
|
||||
EndpointAccessCapability(endpoint_ref, record.access_token_hash)
|
||||
if record.access_token_hash is not None
|
||||
else None
|
||||
)
|
||||
return DeliveryEndpoint(
|
||||
ref=endpoint_ref,
|
||||
configuration=configuration,
|
||||
address_hash=record.address_hash,
|
||||
access_capability=capability,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def delivery_attempt_to_record(attempt: DeliveryAttempt) -> HumanInputV2FormDeliveryAttempt:
|
||||
response = (
|
||||
FormDeliveryProviderResponse(attempt.provider_response.to_mapping())
|
||||
if attempt.provider_response is not None
|
||||
else None
|
||||
)
|
||||
record = HumanInputV2FormDeliveryAttempt(
|
||||
tenant_id=str(attempt.endpoint_ref.form_ref.workspace_id),
|
||||
form_id=str(attempt.endpoint_ref.form_ref.form_id),
|
||||
endpoint_id=str(attempt.endpoint_ref.endpoint_id),
|
||||
attempt_number=attempt.attempt_number,
|
||||
status=attempt.status,
|
||||
scheduled_at=attempt.scheduled_at.value,
|
||||
started_at=attempt.started_at.value if attempt.started_at is not None else None,
|
||||
finished_at=attempt.finished_at.value if attempt.finished_at is not None else None,
|
||||
provider_message_id=attempt.provider_message_id,
|
||||
failure_code=attempt.failure_code,
|
||||
failure_reason=attempt.failure_reason,
|
||||
provider_response=response,
|
||||
)
|
||||
_set_record_identity(
|
||||
record, record_id=str(attempt.id), created_at=attempt.created_at, updated_at=attempt.updated_at
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def delivery_attempt_from_record(
|
||||
record: HumanInputV2FormDeliveryAttempt,
|
||||
endpoint_record: HumanInputV2FormDeliveryEndpoint,
|
||||
) -> DeliveryAttempt:
|
||||
endpoint = endpoint_from_record(endpoint_record)
|
||||
if (
|
||||
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
|
||||
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
|
||||
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
|
||||
):
|
||||
raise ValueError("delivery attempt owner does not match its endpoint record")
|
||||
return DeliveryAttempt(
|
||||
id=DeliveryAttemptId(record.id),
|
||||
endpoint_ref=endpoint.ref,
|
||||
attempt_number=record.attempt_number,
|
||||
status=record.status,
|
||||
scheduled_at=_timestamp(record.scheduled_at),
|
||||
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
|
||||
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
|
||||
provider_message_id=record.provider_message_id,
|
||||
failure_code=record.failure_code,
|
||||
failure_reason=record.failure_reason,
|
||||
provider_response=(
|
||||
FrozenJSONObject.from_mapping(record.provider_response.root)
|
||||
if record.provider_response is not None
|
||||
else None
|
||||
),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def email_provider_to_record(provider: EmailProviderConfiguration) -> HumanInputEmailProvider:
|
||||
credentials = ResendEmailProviderEncryptedCredentials.model_validate(
|
||||
provider.encrypted_credentials.to_mapping(),
|
||||
)
|
||||
record = HumanInputEmailProvider(
|
||||
provider=provider.provider,
|
||||
sender_email=str(provider.sender_email),
|
||||
encrypted_credentials=credentials,
|
||||
tenant_id=str(provider.workspace_id),
|
||||
sender_name=provider.sender_name,
|
||||
configured_by_account_id=(
|
||||
str(provider.configured_by_account_id) if provider.configured_by_account_id is not None else None
|
||||
),
|
||||
)
|
||||
_set_record_identity(
|
||||
record, record_id=str(provider.id), created_at=provider.created_at, updated_at=provider.updated_at
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def email_provider_from_record(record: HumanInputEmailProvider) -> EmailProviderConfiguration:
|
||||
return EmailProviderConfiguration(
|
||||
id=EmailProviderId(record.id),
|
||||
workspace_id=WorkspaceId(record.tenant_id),
|
||||
provider=record.provider,
|
||||
sender_email=NormalizedEmail(record.sender_email),
|
||||
sender_name=record.sender_name,
|
||||
encrypted_credentials=FrozenJSONObject.from_mapping(
|
||||
_JSON_OBJECT_ADAPTER.validate_python(record.encrypted_credentials.model_dump(mode="json"))
|
||||
),
|
||||
configured_by_account_id=(
|
||||
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
|
||||
),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def upload_capability_to_record(capability: UploadCapability) -> HumanInputV2FormUploadToken:
|
||||
record = HumanInputV2FormUploadToken(
|
||||
tenant_id=str(capability.endpoint_ref.form_ref.workspace_id),
|
||||
app_id=str(capability.app_id),
|
||||
form_id=str(capability.endpoint_ref.form_ref.form_id),
|
||||
endpoint_id=str(capability.endpoint_ref.endpoint_id),
|
||||
upload_token_hash=capability.token_hash,
|
||||
)
|
||||
_set_record_identity(
|
||||
record, record_id=str(capability.id), created_at=capability.created_at, updated_at=capability.updated_at
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def upload_capability_from_record(
|
||||
record: HumanInputV2FormUploadToken,
|
||||
endpoint_record: HumanInputV2FormDeliveryEndpoint,
|
||||
) -> UploadCapability:
|
||||
endpoint = endpoint_from_record(endpoint_record)
|
||||
if (
|
||||
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
|
||||
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
|
||||
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
|
||||
):
|
||||
raise ValueError("upload capability owner does not match its endpoint record")
|
||||
return UploadCapability(
|
||||
id=UploadCapabilityId(record.id),
|
||||
endpoint_ref=endpoint.ref,
|
||||
app_id=AppId(record.app_id),
|
||||
token_hash=record.upload_token_hash,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def upload_file_to_record(association: UploadFileAssociation) -> HumanInputV2FormUploadFile:
|
||||
endpoint_ref = association.capability_ref.endpoint_ref
|
||||
record = HumanInputV2FormUploadFile(
|
||||
tenant_id=str(endpoint_ref.form_ref.workspace_id),
|
||||
app_id=str(association.capability_ref.app_id),
|
||||
form_id=str(endpoint_ref.form_ref.form_id),
|
||||
endpoint_id=str(endpoint_ref.endpoint_id),
|
||||
upload_file_id=association.upload_file_id,
|
||||
upload_token_id=str(association.capability_ref.capability_id),
|
||||
)
|
||||
_set_record_identity(
|
||||
record,
|
||||
record_id=str(association.id),
|
||||
created_at=association.created_at,
|
||||
updated_at=association.updated_at,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def upload_file_from_record(
|
||||
record: HumanInputV2FormUploadFile,
|
||||
capability_record: HumanInputV2FormUploadToken,
|
||||
endpoint_record: HumanInputV2FormDeliveryEndpoint,
|
||||
) -> UploadFileAssociation:
|
||||
capability = upload_capability_from_record(capability_record, endpoint_record)
|
||||
if (
|
||||
capability.id != UploadCapabilityId(record.upload_token_id)
|
||||
or capability.endpoint_ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
|
||||
or capability.endpoint_ref.form_ref.form_id != FormId(record.form_id)
|
||||
or capability.endpoint_ref.endpoint_id != DeliveryEndpointId(record.endpoint_id)
|
||||
or capability.app_id != AppId(record.app_id)
|
||||
):
|
||||
raise ValueError("uploaded file owner does not match its capability record")
|
||||
return UploadFileAssociation(
|
||||
id=UploadFileAssociationId(record.id),
|
||||
capability_ref=capability.ref,
|
||||
upload_file_id=record.upload_file_id,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
@@ -0,0 +1,280 @@
|
||||
"""SQLAlchemy Human Input v2 Form adapter.
|
||||
|
||||
The public operations own their transaction and eager-loading shape. Every
|
||||
query carries the complete workspace/form/child owner predicates because domain
|
||||
references are navigation values, not authorization tokens. ORM instances never
|
||||
cross this boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, selectinload, sessionmaker
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpointRef,
|
||||
FormCreation,
|
||||
FormDefinitionProjection,
|
||||
FormDeliveryProjection,
|
||||
FormRef,
|
||||
HumanInputForm,
|
||||
UploadCapability,
|
||||
UploadFileAssociation,
|
||||
)
|
||||
from core.human_input_v2.shared import WorkspaceId
|
||||
from models.human_input_v2 import (
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
HumanInputV2FormUploadToken,
|
||||
)
|
||||
from models.model import UploadFile
|
||||
|
||||
from .mappers import (
|
||||
delivery_attempt_from_record,
|
||||
delivery_attempt_to_record,
|
||||
endpoint_from_record,
|
||||
endpoint_to_record,
|
||||
form_from_record,
|
||||
form_to_record,
|
||||
grant_from_record,
|
||||
grant_to_record,
|
||||
upload_capability_from_record,
|
||||
upload_capability_to_record,
|
||||
upload_file_from_record,
|
||||
upload_file_to_record,
|
||||
)
|
||||
|
||||
|
||||
class FormPersistenceError(RuntimeError):
|
||||
"""A Form Core persistence operation could not preserve its contract."""
|
||||
|
||||
|
||||
class SQLAlchemyFormRepository:
|
||||
"""Transactional, operation-oriented adapter for one form aggregate boundary."""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
||||
self._session_maker = session_maker
|
||||
|
||||
def create_form(self, creation: FormCreation) -> HumanInputForm:
|
||||
"""Persist the complete form/grant/endpoint snapshot in one transaction."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
session.add(form_to_record(creation.form))
|
||||
session.add_all(grant_to_record(grant) for grant in creation.form.grants)
|
||||
session.add_all(endpoint_to_record(endpoint) for endpoint in creation.endpoints)
|
||||
session.flush()
|
||||
return creation.form
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to create Human Input form snapshot") from error
|
||||
|
||||
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
|
||||
"""Load one form and its grant membership in a fixed two-query shape."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
record = session.scalar(
|
||||
select(HumanInputV2Form)
|
||||
.options(selectinload(HumanInputV2Form.grants))
|
||||
.where(
|
||||
HumanInputV2Form.tenant_id == str(form_ref.workspace_id),
|
||||
HumanInputV2Form.id == str(form_ref.form_id),
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return None
|
||||
return form_from_record(record, tuple(record.grants))
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to load Human Input form lifecycle state") from error
|
||||
|
||||
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
|
||||
"""Load exactly one endpoint, grant, and form for delivery."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
row = session.execute(self._endpoint_graph_statement(endpoint_ref)).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
endpoint_record, grant_record, form_record = row
|
||||
form = form_from_record(form_record, (grant_record,))
|
||||
return FormDeliveryProjection(
|
||||
form_ref=form.ref,
|
||||
grant=grant_from_record(grant_record),
|
||||
endpoint=endpoint_from_record(endpoint_record),
|
||||
definition=form.definition,
|
||||
rendered_content=form.rendered_content,
|
||||
)
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to load Human Input delivery projection") from error
|
||||
|
||||
def load_definition_by_endpoint_token(
|
||||
self,
|
||||
*,
|
||||
workspace_id: WorkspaceId,
|
||||
token_hash: str,
|
||||
) -> FormDefinitionProjection | None:
|
||||
"""Resolve a token to a read model without returning grant authority."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
row = session.execute(
|
||||
self._endpoint_graph_base().where(
|
||||
HumanInputV2FormDeliveryEndpoint.tenant_id == str(workspace_id),
|
||||
HumanInputV2FormDeliveryEndpoint.access_token_hash == token_hash,
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
endpoint_record, grant_record, form_record = row
|
||||
form = form_from_record(form_record, (grant_record,))
|
||||
return FormDefinitionProjection(
|
||||
form_ref=form.ref,
|
||||
endpoint_ref=endpoint_from_record(endpoint_record).ref,
|
||||
definition=form.definition,
|
||||
rendered_content=form.rendered_content,
|
||||
status=form.status,
|
||||
node_timeout_at=form.node_timeout_at,
|
||||
global_expires_at=form.global_expires_at,
|
||||
)
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to load Human Input token projection") from error
|
||||
|
||||
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
|
||||
"""Append one attempt after validating its full endpoint owner chain."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
endpoint_record = self._load_endpoint_record(session, attempt.endpoint_ref)
|
||||
if endpoint_record is None:
|
||||
raise ValueError("delivery attempt endpoint scope does not exist")
|
||||
record = delivery_attempt_to_record(attempt)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return delivery_attempt_from_record(record, endpoint_record)
|
||||
except ValueError:
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to append Human Input delivery attempt") from error
|
||||
|
||||
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
|
||||
"""Persist one capability only when endpoint and app ownership match."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
row = session.execute(self._endpoint_graph_statement(capability.endpoint_ref)).one_or_none()
|
||||
if row is None:
|
||||
raise ValueError("upload capability endpoint scope does not exist")
|
||||
endpoint_record, _grant_record, form_record = row
|
||||
if form_record.app_id != str(capability.app_id):
|
||||
raise ValueError("upload capability app scope does not match the form")
|
||||
record = upload_capability_to_record(capability)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return upload_capability_from_record(record, endpoint_record)
|
||||
except ValueError:
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to create Human Input upload capability") from error
|
||||
|
||||
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
|
||||
"""Persist a file only after resolving its token and UploadFile workspace owners."""
|
||||
|
||||
capability_ref = association.capability_ref
|
||||
endpoint_ref = capability_ref.endpoint_ref
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
row = session.execute(
|
||||
select(HumanInputV2FormUploadToken, HumanInputV2FormDeliveryEndpoint)
|
||||
.join(
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
sa.and_(
|
||||
HumanInputV2FormDeliveryEndpoint.id == HumanInputV2FormUploadToken.endpoint_id,
|
||||
HumanInputV2FormDeliveryEndpoint.tenant_id == HumanInputV2FormUploadToken.tenant_id,
|
||||
HumanInputV2FormDeliveryEndpoint.form_id == HumanInputV2FormUploadToken.form_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
HumanInputV2Form,
|
||||
sa.and_(
|
||||
HumanInputV2Form.id == HumanInputV2FormUploadToken.form_id,
|
||||
HumanInputV2Form.tenant_id == HumanInputV2FormUploadToken.tenant_id,
|
||||
HumanInputV2Form.app_id == HumanInputV2FormUploadToken.app_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
HumanInputV2FormUploadToken.id == str(capability_ref.capability_id),
|
||||
HumanInputV2FormUploadToken.tenant_id == str(endpoint_ref.form_ref.workspace_id),
|
||||
HumanInputV2FormUploadToken.app_id == str(capability_ref.app_id),
|
||||
HumanInputV2FormUploadToken.form_id == str(endpoint_ref.form_ref.form_id),
|
||||
HumanInputV2FormUploadToken.endpoint_id == str(endpoint_ref.endpoint_id),
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
raise ValueError("upload file capability scope does not match")
|
||||
capability_record, endpoint_record = row
|
||||
upload_file_id = session.scalar(
|
||||
select(UploadFile.id).where(
|
||||
UploadFile.id == association.upload_file_id,
|
||||
UploadFile.tenant_id == str(endpoint_ref.form_ref.workspace_id),
|
||||
)
|
||||
)
|
||||
if upload_file_id is None:
|
||||
raise ValueError("upload file workspace scope does not exist")
|
||||
record = upload_file_to_record(association)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return upload_file_from_record(record, capability_record, endpoint_record)
|
||||
except ValueError:
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
raise FormPersistenceError("failed to associate Human Input uploaded file") from error
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_graph_base() -> sa.Select[
|
||||
tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]
|
||||
]:
|
||||
return (
|
||||
select(HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form)
|
||||
.join(
|
||||
HumanInputV2FormApproverGrant,
|
||||
sa.and_(
|
||||
HumanInputV2FormApproverGrant.id == HumanInputV2FormDeliveryEndpoint.approver_grant_id,
|
||||
HumanInputV2FormApproverGrant.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
|
||||
HumanInputV2FormApproverGrant.form_id == HumanInputV2FormDeliveryEndpoint.form_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
HumanInputV2Form,
|
||||
sa.and_(
|
||||
HumanInputV2Form.id == HumanInputV2FormDeliveryEndpoint.form_id,
|
||||
HumanInputV2Form.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _endpoint_graph_statement(
|
||||
cls,
|
||||
endpoint_ref: DeliveryEndpointRef,
|
||||
) -> sa.Select[tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]]:
|
||||
return cls._endpoint_graph_base().where(
|
||||
HumanInputV2FormDeliveryEndpoint.tenant_id == str(endpoint_ref.form_ref.workspace_id),
|
||||
HumanInputV2FormDeliveryEndpoint.form_id == str(endpoint_ref.form_ref.form_id),
|
||||
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(endpoint_ref.grant_ref.grant_id),
|
||||
HumanInputV2FormDeliveryEndpoint.id == str(endpoint_ref.endpoint_id),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _load_endpoint_record(
|
||||
cls,
|
||||
session: Session,
|
||||
endpoint_ref: DeliveryEndpointRef,
|
||||
) -> HumanInputV2FormDeliveryEndpoint | None:
|
||||
row = session.execute(cls._endpoint_graph_statement(endpoint_ref)).one_or_none()
|
||||
return row[0] if row is not None else None
|
||||
@@ -0,0 +1,5 @@
|
||||
"""SQLAlchemy persistence adapter for the IM Control Plane domain."""
|
||||
|
||||
from .repository import SQLAlchemyIMControlPlaneRepository
|
||||
|
||||
__all__ = ["SQLAlchemyIMControlPlaneRepository"]
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Explicit bidirectional mappings for IM domain values and ORM records."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from core.human_input_v2.entities import IMBindingScope, IMSyncRemovalReason
|
||||
from core.human_input_v2.im_integration import (
|
||||
EncryptedCredentials,
|
||||
IMBinding,
|
||||
IMIdentity,
|
||||
IMIntegration,
|
||||
IMSyncRun,
|
||||
IntegrationRevisionToken,
|
||||
OpaqueProviderPayload,
|
||||
ProviderTenantIdentity,
|
||||
SyncContactSnapshot,
|
||||
SyncIdentitySnapshot,
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from models.human_input_v2 import (
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
IMIdentityRawPayload,
|
||||
IMIntegrationEncryptedCredentials,
|
||||
IMSyncContactSnapshot,
|
||||
IMSyncDirectoryEntryPayload,
|
||||
IMSyncIdentitySnapshot,
|
||||
)
|
||||
|
||||
_CREDENTIAL_ADAPTER: TypeAdapter[IMIntegrationEncryptedCredentials] = TypeAdapter(IMIntegrationEncryptedCredentials)
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> UtcTimestamp:
|
||||
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
|
||||
|
||||
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
|
||||
|
||||
|
||||
def integration_from_record(record: HumanInputIMIntegration) -> IMIntegration:
|
||||
"""Map one Integration record into its CAS aggregate."""
|
||||
|
||||
if record.provider_tenant_id is None:
|
||||
raise ValueError("integration record is missing provider_tenant_id")
|
||||
credential_values = record.encrypted_credentials.model_dump(mode="json", exclude_none=True)
|
||||
credential_values.pop("provider", None)
|
||||
return IMIntegration(
|
||||
id=IntegrationId(record.id),
|
||||
workspace_id=WorkspaceId(record.tenant_id) if record.tenant_id is not None else None,
|
||||
provider_tenant=ProviderTenantIdentity(record.provider, record.provider_tenant_id),
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping(credential_values),
|
||||
configured_by_account_id=(
|
||||
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
|
||||
),
|
||||
callback_url=record.callback_url,
|
||||
config_version=record.config_version,
|
||||
status=record.status,
|
||||
safe_status_reason=record.safe_status_reason,
|
||||
last_checked_at=_timestamp(record.last_checked_at) if record.last_checked_at is not None else None,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def integration_to_record(integration: IMIntegration) -> HumanInputIMIntegration:
|
||||
"""Map one Integration aggregate into a detached persistence record."""
|
||||
|
||||
credential_values = integration.encrypted_credentials.to_mapping()
|
||||
credential_values["provider"] = integration.provider_tenant.provider.value
|
||||
credentials = _CREDENTIAL_ADAPTER.validate_python(credential_values)
|
||||
record = HumanInputIMIntegration(
|
||||
provider=integration.provider_tenant.provider,
|
||||
encrypted_credentials=credentials,
|
||||
tenant_id=str(integration.workspace_id) if integration.workspace_id is not None else None,
|
||||
provider_tenant_id=integration.provider_tenant.provider_tenant_id,
|
||||
status=integration.status,
|
||||
config_version=integration.config_version,
|
||||
configured_by_account_id=(
|
||||
str(integration.configured_by_account_id) if integration.configured_by_account_id is not None else None
|
||||
),
|
||||
callback_url=integration.callback_url,
|
||||
safe_status_reason=integration.safe_status_reason,
|
||||
last_checked_at=integration.last_checked_at.value if integration.last_checked_at is not None else None,
|
||||
)
|
||||
record.id = str(integration.id)
|
||||
record.created_at = integration.created_at.value
|
||||
record.updated_at = integration.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def identity_from_record(record: HumanInputIMIdentity) -> IMIdentity:
|
||||
"""Map one current provider identity record into a domain value."""
|
||||
|
||||
return IMIdentity(
|
||||
id=IMIdentityId(record.id),
|
||||
integration_id=IntegrationId(record.integration_id),
|
||||
provider=record.provider,
|
||||
provider_user_id=record.provider_user_id,
|
||||
display_name=record.display_name,
|
||||
normalized_name=record.normalized_name,
|
||||
email=record.email,
|
||||
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
|
||||
raw_payload=OpaqueProviderPayload.from_mapping(record.raw_payload.root),
|
||||
last_seen_sync_run_id=(
|
||||
IMSyncRunId(record.last_seen_sync_run_id) if record.last_seen_sync_run_id is not None else None
|
||||
),
|
||||
last_seen_at=_timestamp(record.last_seen_at) if record.last_seen_at is not None else None,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def identity_to_record(identity: IMIdentity) -> HumanInputIMIdentity:
|
||||
"""Map one current provider identity into a detached record."""
|
||||
|
||||
record = HumanInputIMIdentity(
|
||||
integration_id=str(identity.integration_id),
|
||||
provider=identity.provider,
|
||||
provider_user_id=identity.provider_user_id,
|
||||
display_name=identity.display_name,
|
||||
normalized_name=identity.normalized_name,
|
||||
email=identity.email,
|
||||
normalized_email=str(identity.normalized_email) if identity.normalized_email is not None else None,
|
||||
raw_payload=IMIdentityRawPayload(identity.raw_payload.to_mapping()),
|
||||
last_seen_sync_run_id=(
|
||||
str(identity.last_seen_sync_run_id) if identity.last_seen_sync_run_id is not None else None
|
||||
),
|
||||
last_seen_at=identity.last_seen_at.value if identity.last_seen_at is not None else None,
|
||||
)
|
||||
record.id = str(identity.id)
|
||||
record.created_at = identity.created_at.value
|
||||
record.updated_at = identity.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def binding_from_record(record: HumanInputIMBinding) -> IMBinding:
|
||||
"""Map one current binding record into a domain value."""
|
||||
|
||||
return IMBinding(
|
||||
id=IMBindingId(record.id),
|
||||
integration_id=IntegrationId(record.integration_id),
|
||||
scope=IMBindingScope(record.scope),
|
||||
scope_id=record.scope_id,
|
||||
contact_id=ContactId(record.contact_id),
|
||||
identity_id=IMIdentityId(record.im_identity_id),
|
||||
provider=record.provider,
|
||||
bound_by_account_id=AccountId(record.bound_by_account_id) if record.bound_by_account_id is not None else None,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def binding_to_record(binding: IMBinding) -> HumanInputIMBinding:
|
||||
"""Map one current binding into a detached record."""
|
||||
|
||||
record = HumanInputIMBinding(
|
||||
integration_id=str(binding.integration_id),
|
||||
scope=binding.scope,
|
||||
scope_id=binding.scope_id,
|
||||
contact_id=str(binding.contact_id),
|
||||
im_identity_id=str(binding.identity_id),
|
||||
provider=binding.provider,
|
||||
bound_by_account_id=str(binding.bound_by_account_id) if binding.bound_by_account_id is not None else None,
|
||||
)
|
||||
record.id = str(binding.id)
|
||||
record.created_at = binding.created_at.value
|
||||
record.updated_at = binding.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
|
||||
"""Map one sync run record into its independent aggregate."""
|
||||
|
||||
return IMSyncRun(
|
||||
id=IMSyncRunId(record.id),
|
||||
integration_revision=IntegrationRevisionToken(
|
||||
IntegrationId(record.integration_id), record.integration_config_version
|
||||
),
|
||||
provider=record.provider,
|
||||
status=record.status,
|
||||
added_count=record.added_count,
|
||||
not_matched_count=record.not_matched_count,
|
||||
failed_count=record.failed_count,
|
||||
removed_count=record.removed_count,
|
||||
skipped_count=record.skipped_count,
|
||||
started_by_account_id=(
|
||||
AccountId(record.started_by_account_id) if record.started_by_account_id is not None else None
|
||||
),
|
||||
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
|
||||
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
|
||||
error_code=record.error_code,
|
||||
error_message=record.error_message,
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun:
|
||||
"""Map one sync aggregate into a detached record."""
|
||||
|
||||
record = HumanInputIMSyncRun(
|
||||
integration_id=str(run.integration_revision.integration_id),
|
||||
integration_config_version=run.integration_revision.config_version,
|
||||
provider=run.provider,
|
||||
status=run.status,
|
||||
added_count=run.added_count,
|
||||
not_matched_count=run.not_matched_count,
|
||||
failed_count=run.failed_count,
|
||||
removed_count=run.removed_count,
|
||||
skipped_count=run.skipped_count,
|
||||
started_by_account_id=str(run.started_by_account_id) if run.started_by_account_id is not None else None,
|
||||
started_at=run.started_at.value if run.started_at is not None else None,
|
||||
finished_at=run.finished_at.value if run.finished_at is not None else None,
|
||||
error_code=run.error_code,
|
||||
error_message=run.error_message,
|
||||
)
|
||||
record.id = str(run.id)
|
||||
record.created_at = run.created_at.value
|
||||
record.updated_at = run.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def sync_result_from_record(record: HumanInputIMSyncResult) -> SyncResultFact:
|
||||
"""Map one append-only result record into an immutable domain fact."""
|
||||
|
||||
contact_snapshot = record.contact_snapshot
|
||||
identity_snapshot = record.identity_snapshot
|
||||
return SyncResultFact(
|
||||
id=IMSyncResultId(record.id),
|
||||
integration_id=IntegrationId(record.integration_id),
|
||||
sync_run_id=IMSyncRunId(record.sync_run_id),
|
||||
result_type=record.result_type,
|
||||
provider_user_id=record.provider_user_id,
|
||||
display_name=record.display_name,
|
||||
email=record.email,
|
||||
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
|
||||
contact_id=ContactId(record.contact_id) if record.contact_id is not None else None,
|
||||
identity_id=IMIdentityId(record.im_identity_id) if record.im_identity_id is not None else None,
|
||||
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
|
||||
removal_reason=IMSyncRemovalReason(record.removal_reason) if record.removal_reason is not None else None,
|
||||
reason_code=record.reason_code,
|
||||
reason_message=record.reason_message,
|
||||
directory_entry_payload=(
|
||||
OpaqueProviderPayload.from_mapping(record.directory_entry_payload.root)
|
||||
if record.directory_entry_payload is not None
|
||||
else None
|
||||
),
|
||||
contact_snapshot=(
|
||||
SyncContactSnapshot(
|
||||
contact_id=ContactId(contact_snapshot.contact_id),
|
||||
name=contact_snapshot.name,
|
||||
email=contact_snapshot.email,
|
||||
avatar_file_id=contact_snapshot.avatar_file_id,
|
||||
)
|
||||
if contact_snapshot is not None
|
||||
else None
|
||||
),
|
||||
identity_snapshot=(
|
||||
SyncIdentitySnapshot(
|
||||
identity_id=IMIdentityId(identity_snapshot.identity_id),
|
||||
provider=identity_snapshot.provider,
|
||||
provider_user_id=identity_snapshot.provider_user_id,
|
||||
display_name=identity_snapshot.display_name,
|
||||
email=identity_snapshot.email,
|
||||
)
|
||||
if identity_snapshot is not None
|
||||
else None
|
||||
),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def sync_result_to_record(result: SyncResultFact) -> HumanInputIMSyncResult:
|
||||
"""Map one immutable sync result fact into a detached record."""
|
||||
|
||||
contact_snapshot = result.contact_snapshot
|
||||
identity_snapshot = result.identity_snapshot
|
||||
record = HumanInputIMSyncResult(
|
||||
integration_id=str(result.integration_id),
|
||||
sync_run_id=str(result.sync_run_id),
|
||||
result_type=result.result_type,
|
||||
provider_user_id=result.provider_user_id,
|
||||
display_name=result.display_name,
|
||||
email=result.email,
|
||||
normalized_email=str(result.normalized_email) if result.normalized_email is not None else None,
|
||||
contact_id=str(result.contact_id) if result.contact_id is not None else None,
|
||||
im_identity_id=str(result.identity_id) if result.identity_id is not None else None,
|
||||
im_binding_id=str(result.binding_id) if result.binding_id is not None else None,
|
||||
removal_reason=result.removal_reason,
|
||||
reason_code=result.reason_code,
|
||||
reason_message=result.reason_message,
|
||||
directory_entry_payload=(
|
||||
IMSyncDirectoryEntryPayload(result.directory_entry_payload.to_mapping())
|
||||
if result.directory_entry_payload is not None
|
||||
else None
|
||||
),
|
||||
contact_snapshot=(
|
||||
IMSyncContactSnapshot(
|
||||
contact_id=str(contact_snapshot.contact_id),
|
||||
name=contact_snapshot.name,
|
||||
email=contact_snapshot.email,
|
||||
avatar_file_id=contact_snapshot.avatar_file_id,
|
||||
)
|
||||
if contact_snapshot is not None
|
||||
else None
|
||||
),
|
||||
identity_snapshot=(
|
||||
IMSyncIdentitySnapshot(
|
||||
identity_id=str(identity_snapshot.identity_id),
|
||||
provider=identity_snapshot.provider,
|
||||
provider_user_id=identity_snapshot.provider_user_id,
|
||||
display_name=identity_snapshot.display_name,
|
||||
email=identity_snapshot.email,
|
||||
)
|
||||
if identity_snapshot is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
record.id = str(result.id)
|
||||
record.created_at = result.created_at.value
|
||||
record.updated_at = result.updated_at.value
|
||||
return record
|
||||
@@ -0,0 +1,689 @@
|
||||
"""SQLAlchemy IM Control Plane adapter.
|
||||
|
||||
Configuration transitions, active-run creation, and reconciliation apply each
|
||||
own their complete transaction. Integration locks serialize single-active-run
|
||||
decisions. Deployment-wide creation additionally locks the stable ``DifySetup``
|
||||
owner because nullable uniqueness cannot enforce the EE singleton. ORM records
|
||||
never cross this boundary, and aggregate relationships are loaded explicitly
|
||||
because their model relationships use ``lazy="raise"``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload, sessionmaker
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactSnapshot
|
||||
from core.human_input_v2.entities import (
|
||||
IMBindingScope,
|
||||
IMProvider,
|
||||
IMSyncRemovalReason,
|
||||
IMSyncResultType,
|
||||
IMSyncRunStatus,
|
||||
)
|
||||
from core.human_input_v2.im_integration import (
|
||||
ActiveRunDecision,
|
||||
ActiveRunDecisionKind,
|
||||
ApplyReconciliationResult,
|
||||
ApplyReconciliationStatus,
|
||||
BindingResolutionKind,
|
||||
BindingResolutionResult,
|
||||
ConfigurationTransition,
|
||||
ConfigurationTransitionKind,
|
||||
EffectiveBindingResolver,
|
||||
IMBinding,
|
||||
IMIdentity,
|
||||
IMIntegration,
|
||||
IMIntegrationState,
|
||||
IMSyncRun,
|
||||
IntegrationDeletion,
|
||||
IntegrationRevisionToken,
|
||||
MatchKind,
|
||||
ReconciliationAction,
|
||||
ReconciliationPlan,
|
||||
ReconciliationSnapshot,
|
||||
StaleRevision,
|
||||
SyncContactSnapshot,
|
||||
SyncIdentitySnapshot,
|
||||
SyncResultFact,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncResultId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.account import Account, AccountStatus
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
)
|
||||
from models.model import DifySetup
|
||||
from repositories.human_input_v2.contact_directory.mappers import contact_from_record
|
||||
|
||||
from .mappers import (
|
||||
binding_from_record,
|
||||
binding_to_record,
|
||||
identity_from_record,
|
||||
identity_to_record,
|
||||
integration_from_record,
|
||||
integration_to_record,
|
||||
sync_result_from_record,
|
||||
sync_result_to_record,
|
||||
sync_run_from_record,
|
||||
sync_run_to_record,
|
||||
)
|
||||
|
||||
|
||||
class SQLAlchemyIMControlPlaneRepository:
|
||||
"""Transactional adapter for configuration, sync, and binding invariants."""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
||||
self._session_maker = session_maker
|
||||
|
||||
def create_integration(self, integration: IMIntegration) -> IMIntegration:
|
||||
"""Create the first configuration after serializing its owner scope."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
if integration.workspace_id is None:
|
||||
self._lock_deployment_owner(session)
|
||||
existing_deployment_integration_id = session.scalar(
|
||||
select(HumanInputIMIntegration.id).where(HumanInputIMIntegration.tenant_id.is_(None)).limit(1)
|
||||
)
|
||||
if existing_deployment_integration_id is not None:
|
||||
raise ValueError("deployment-wide IM integration already exists")
|
||||
record = integration_to_record(integration)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return integration_from_record(record)
|
||||
|
||||
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
|
||||
"""Apply a complete-token rotation or replacement in one transaction."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
current = session.scalar(self._locked_integration_statement(transition.expected_revision))
|
||||
if current is None:
|
||||
return StaleRevision(
|
||||
transition.expected_revision,
|
||||
self._current_revision(session, transition.expected_revision.integration_id),
|
||||
)
|
||||
|
||||
if transition.kind is ConfigurationTransitionKind.CREDENTIAL_ROTATION:
|
||||
self._copy_integration_values(current, transition.integration)
|
||||
session.flush()
|
||||
return integration_from_record(current)
|
||||
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMBinding).where(
|
||||
HumanInputIMBinding.integration_id == str(transition.expected_revision.integration_id)
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.integration_id == str(transition.expected_revision.integration_id)
|
||||
)
|
||||
)
|
||||
session.delete(current)
|
||||
session.flush()
|
||||
replacement = integration_to_record(transition.integration)
|
||||
session.add(replacement)
|
||||
session.flush()
|
||||
return integration_from_record(replacement)
|
||||
|
||||
def compare_and_swap_delete(self, deletion: IntegrationDeletion | StaleRevision) -> None | StaleRevision:
|
||||
"""Delete current configuration and current children under complete CAS."""
|
||||
|
||||
if isinstance(deletion, StaleRevision):
|
||||
return deletion
|
||||
with self._session_maker() as session, session.begin():
|
||||
current = session.scalar(self._locked_integration_statement(deletion.expected_revision))
|
||||
if current is None:
|
||||
return StaleRevision(
|
||||
deletion.expected_revision,
|
||||
self._current_revision(session, deletion.expected_revision.integration_id),
|
||||
)
|
||||
integration_id = str(deletion.expected_revision.integration_id)
|
||||
session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == integration_id))
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id == integration_id)
|
||||
)
|
||||
session.delete(current)
|
||||
return None
|
||||
|
||||
def create_or_get_active_run(
|
||||
self,
|
||||
integration_revision: IntegrationRevisionToken,
|
||||
*,
|
||||
sync_run_id: IMSyncRunId,
|
||||
started_by_account_id: AccountId | None,
|
||||
now: UtcTimestamp,
|
||||
) -> ActiveRunDecision:
|
||||
"""Serialize trigger decisions by locking the owning Integration row."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
integration = session.scalar(self._locked_integration_statement(integration_revision))
|
||||
if integration is None:
|
||||
return ActiveRunDecision(
|
||||
kind=ActiveRunDecisionKind.STALE_REVISION,
|
||||
run=None,
|
||||
stale_revision=StaleRevision(
|
||||
integration_revision,
|
||||
self._current_revision(session, integration_revision.integration_id),
|
||||
),
|
||||
)
|
||||
existing = session.scalar(
|
||||
select(HumanInputIMSyncRun)
|
||||
.where(
|
||||
HumanInputIMSyncRun.integration_id == str(integration_revision.integration_id),
|
||||
HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)),
|
||||
)
|
||||
.order_by(HumanInputIMSyncRun.created_at, HumanInputIMSyncRun.id)
|
||||
.limit(1)
|
||||
)
|
||||
if existing is not None:
|
||||
return ActiveRunDecision(ActiveRunDecisionKind.EXISTING_ACTIVE, sync_run_from_record(existing))
|
||||
run = IMSyncRun.create(
|
||||
sync_run_id=sync_run_id,
|
||||
integration_revision=integration_revision,
|
||||
provider=integration.provider,
|
||||
started_by_account_id=started_by_account_id,
|
||||
now=now,
|
||||
)
|
||||
record = sync_run_to_record(run)
|
||||
session.add(record)
|
||||
session.flush()
|
||||
return ActiveRunDecision(ActiveRunDecisionKind.CREATED, sync_run_from_record(record))
|
||||
|
||||
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
|
||||
"""Load current identities, bindings, and owner-scoped Contact facts."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
run = session.get_one(HumanInputIMSyncRun, str(sync_run_id))
|
||||
integration = session.get_one(HumanInputIMIntegration, run.integration_id)
|
||||
identity_records = session.scalars(
|
||||
select(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.integration_id == run.integration_id,
|
||||
HumanInputIMIdentity.provider == run.provider,
|
||||
)
|
||||
).all()
|
||||
binding_records = session.scalars(
|
||||
select(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == run.integration_id)
|
||||
).all()
|
||||
contact_statement = select(HumanInputContact)
|
||||
if integration.tenant_id is not None:
|
||||
contact_statement = contact_statement.where(
|
||||
sa.or_(
|
||||
HumanInputContact.tenant_id == integration.tenant_id,
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
)
|
||||
)
|
||||
contact_records = session.scalars(contact_statement).all()
|
||||
account_ids = {record.account_id for record in contact_records if record.account_id is not None}
|
||||
unavailable_account_ids = set(
|
||||
session.scalars(
|
||||
select(Account.id).where(
|
||||
Account.id.in_(account_ids),
|
||||
Account.status != AccountStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
if account_ids
|
||||
else ()
|
||||
)
|
||||
contacts = tuple(contact_from_record(record) for record in contact_records)
|
||||
return ReconciliationSnapshot(
|
||||
identities=tuple(identity_from_record(record) for record in identity_records),
|
||||
bindings=tuple(binding_from_record(record) for record in binding_records),
|
||||
contacts=tuple(
|
||||
ContactSnapshot(
|
||||
contact,
|
||||
contact.account_id is None or str(contact.account_id) not in unavailable_account_ids,
|
||||
)
|
||||
for contact in contacts
|
||||
),
|
||||
)
|
||||
|
||||
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
|
||||
"""Idempotently apply one plan using the persisted run capture as authority."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
run_record = session.scalar(
|
||||
select(HumanInputIMSyncRun).where(HumanInputIMSyncRun.id == str(plan.sync_run_id)).with_for_update()
|
||||
)
|
||||
if run_record is None:
|
||||
raise ValueError("sync run not found")
|
||||
captured_revision = IntegrationRevisionToken(
|
||||
IntegrationId(run_record.integration_id),
|
||||
run_record.integration_config_version,
|
||||
)
|
||||
if plan.integration_revision != captured_revision:
|
||||
raise ValueError("sync run revision does not match plan")
|
||||
if plan.provider is not run_record.provider:
|
||||
raise ValueError("sync run provider does not match plan")
|
||||
|
||||
existing_results = self._load_result_records(session, plan.sync_run_id)
|
||||
if run_record.status in (IMSyncRunStatus.SUCCEEDED, IMSyncRunStatus.FAILED):
|
||||
return ApplyReconciliationResult(
|
||||
ApplyReconciliationStatus.ALREADY_APPLIED,
|
||||
sync_run_from_record(run_record),
|
||||
tuple(sync_result_from_record(record) for record in existing_results),
|
||||
)
|
||||
|
||||
integration_record = session.scalar(self._locked_integration_statement(captured_revision))
|
||||
if integration_record is None or integration_record.provider is not run_record.provider:
|
||||
stale_result = self._stale_result(plan, now)
|
||||
self._append_result_record(session, stale_result)
|
||||
run_record.status = IMSyncRunStatus.FAILED
|
||||
run_record.failed_count = 1
|
||||
run_record.started_at = run_record.started_at or now.value
|
||||
run_record.finished_at = now.value
|
||||
run_record.error_code = "stale_integration_revision"
|
||||
run_record.error_message = "Integration configuration changed before reconciliation apply."
|
||||
run_record.updated_at = now.value
|
||||
session.flush()
|
||||
return ApplyReconciliationResult(
|
||||
ApplyReconciliationStatus.STALE_REVISION,
|
||||
sync_run_from_record(run_record),
|
||||
(stale_result,),
|
||||
)
|
||||
|
||||
results: list[SyncResultFact] = []
|
||||
for action in plan.actions:
|
||||
result = self._apply_action(session, plan, action, now)
|
||||
self._append_result_record(session, result)
|
||||
results.append(result)
|
||||
for identity_id in plan.removed_identity_ids:
|
||||
removal_results = self._remove_identity(session, plan, identity_id, now)
|
||||
for removal_result in removal_results:
|
||||
self._append_result_record(session, removal_result)
|
||||
results.append(removal_result)
|
||||
|
||||
run_record.status = IMSyncRunStatus.SUCCEEDED
|
||||
run_record.added_count = sum(result.result_type is IMSyncResultType.ADDED for result in results)
|
||||
run_record.not_matched_count = sum(result.result_type is IMSyncResultType.NOT_MATCHED for result in results)
|
||||
run_record.failed_count = sum(result.result_type is IMSyncResultType.FAILED for result in results)
|
||||
run_record.removed_count = sum(result.result_type is IMSyncResultType.REMOVED for result in results)
|
||||
run_record.skipped_count = sum(result.result_type is IMSyncResultType.SKIPPED for result in results)
|
||||
run_record.started_at = run_record.started_at or now.value
|
||||
run_record.finished_at = now.value
|
||||
run_record.updated_at = now.value
|
||||
session.flush()
|
||||
return ApplyReconciliationResult(
|
||||
ApplyReconciliationStatus.APPLIED,
|
||||
sync_run_from_record(run_record),
|
||||
tuple(results),
|
||||
)
|
||||
|
||||
def load_integration_state(self, integration_id: IntegrationId) -> IMIntegrationState:
|
||||
"""Eagerly load and map an Integration with all modeled child relationships."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
record = session.scalar(
|
||||
select(HumanInputIMIntegration)
|
||||
.where(HumanInputIMIntegration.id == str(integration_id))
|
||||
.options(
|
||||
selectinload(HumanInputIMIntegration.identities).selectinload(HumanInputIMIdentity.bindings),
|
||||
selectinload(HumanInputIMIntegration.sync_runs).selectinload(HumanInputIMSyncRun.results),
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
raise ValueError("integration not found")
|
||||
identity_records = tuple(record.identities)
|
||||
run_records = tuple(record.sync_runs)
|
||||
return IMIntegrationState(
|
||||
integration=integration_from_record(record),
|
||||
identities=tuple(identity_from_record(item) for item in identity_records),
|
||||
bindings=tuple(
|
||||
binding_from_record(binding) for identity in identity_records for binding in identity.bindings
|
||||
),
|
||||
sync_runs=tuple(sync_run_from_record(item) for item in run_records),
|
||||
sync_results=tuple(sync_result_from_record(result) for run in run_records for result in run.results),
|
||||
)
|
||||
|
||||
def resolve_effective_binding(
|
||||
self,
|
||||
*,
|
||||
integration_id: IntegrationId,
|
||||
provider: IMProvider,
|
||||
workspace_id: WorkspaceId,
|
||||
contact_id: ContactId,
|
||||
) -> BindingResolutionResult:
|
||||
"""Validate Integration ownership before loading consumer-safe binding facts."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
integration_record = session.scalar(
|
||||
select(HumanInputIMIntegration).where(
|
||||
HumanInputIMIntegration.id == str(integration_id),
|
||||
sa.or_(
|
||||
HumanInputIMIntegration.tenant_id == str(workspace_id),
|
||||
HumanInputIMIntegration.tenant_id.is_(None),
|
||||
),
|
||||
)
|
||||
)
|
||||
if integration_record is None or integration_record.provider is not provider:
|
||||
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
|
||||
integration = integration_from_record(integration_record)
|
||||
contact_record = session.scalar(
|
||||
select(HumanInputContact).where(
|
||||
HumanInputContact.id == str(contact_id),
|
||||
sa.or_(HumanInputContact.tenant_id == str(workspace_id), HumanInputContact.tenant_id.is_(None)),
|
||||
)
|
||||
)
|
||||
if contact_record is None:
|
||||
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
|
||||
contact = ContactSnapshot(contact_from_record(contact_record), True)
|
||||
identities = tuple(
|
||||
identity_from_record(record)
|
||||
for record in session.scalars(
|
||||
select(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.integration_id == str(integration_id),
|
||||
HumanInputIMIdentity.provider == provider,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
bindings = tuple(
|
||||
binding_from_record(record)
|
||||
for record in session.scalars(
|
||||
select(HumanInputIMBinding).where(
|
||||
HumanInputIMBinding.integration_id == str(integration_id),
|
||||
HumanInputIMBinding.provider == provider,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
return EffectiveBindingResolver.resolve(
|
||||
integration_revision=integration.revision,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
workspace_id=workspace_id,
|
||||
contact=contact,
|
||||
identities=identities,
|
||||
bindings=bindings,
|
||||
)
|
||||
|
||||
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
|
||||
"""Append diagnostic facts in their own explicit transaction."""
|
||||
|
||||
with self._session_maker() as session, session.begin():
|
||||
for result in results:
|
||||
self._append_result_record(session, result)
|
||||
|
||||
@staticmethod
|
||||
def _locked_integration_statement(
|
||||
revision: IntegrationRevisionToken,
|
||||
) -> sa.Select[tuple[HumanInputIMIntegration]]:
|
||||
"""Build the complete CAS predicate and row lock used by write paths."""
|
||||
|
||||
return (
|
||||
select(HumanInputIMIntegration)
|
||||
.where(
|
||||
HumanInputIMIntegration.id == str(revision.integration_id),
|
||||
HumanInputIMIntegration.config_version == revision.config_version,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _current_revision(session: Session, integration_id: IntegrationId) -> IntegrationRevisionToken | None:
|
||||
record = session.get(HumanInputIMIntegration, str(integration_id))
|
||||
if record is None:
|
||||
return None
|
||||
return IntegrationRevisionToken(IntegrationId(record.id), record.config_version)
|
||||
|
||||
@staticmethod
|
||||
def _lock_deployment_owner(session: Session) -> None:
|
||||
"""Serialize EE singleton decisions on the deployment's stable owner."""
|
||||
|
||||
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
|
||||
if setup_version is None:
|
||||
raise ValueError("deployment setup row is required for deployment-wide IM integration")
|
||||
|
||||
@staticmethod
|
||||
def _copy_integration_values(record: HumanInputIMIntegration, integration: IMIntegration) -> None:
|
||||
mapped = integration_to_record(integration)
|
||||
record.provider = mapped.provider
|
||||
record.encrypted_credentials = mapped.encrypted_credentials
|
||||
record.tenant_id = mapped.tenant_id
|
||||
record.provider_tenant_id = mapped.provider_tenant_id
|
||||
record.status = mapped.status
|
||||
record.config_version = mapped.config_version
|
||||
record.configured_by_account_id = mapped.configured_by_account_id
|
||||
record.callback_url = mapped.callback_url
|
||||
record.safe_status_reason = mapped.safe_status_reason
|
||||
record.last_checked_at = mapped.last_checked_at
|
||||
record.updated_at = mapped.updated_at
|
||||
|
||||
@staticmethod
|
||||
def _load_result_records(session: Session, sync_run_id: IMSyncRunId) -> list[HumanInputIMSyncResult]:
|
||||
return list(
|
||||
session.scalars(
|
||||
select(HumanInputIMSyncResult)
|
||||
.where(HumanInputIMSyncResult.sync_run_id == str(sync_run_id))
|
||||
.order_by(HumanInputIMSyncResult.created_at, HumanInputIMSyncResult.id)
|
||||
).all()
|
||||
)
|
||||
|
||||
def _apply_action(
|
||||
self,
|
||||
session: Session,
|
||||
plan: ReconciliationPlan,
|
||||
action: ReconciliationAction,
|
||||
now: UtcTimestamp,
|
||||
) -> SyncResultFact:
|
||||
identity_record: HumanInputIMIdentity | None = None
|
||||
binding_record: HumanInputIMBinding | None = None
|
||||
contact_record: HumanInputContact | None = None
|
||||
|
||||
if action.identity_id is not None:
|
||||
identity_record = session.scalar(
|
||||
select(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.id == str(action.identity_id),
|
||||
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
|
||||
)
|
||||
)
|
||||
if identity_record is None:
|
||||
raise ValueError("matched identity no longer exists")
|
||||
self._copy_entry_to_identity(identity_record, action, plan, now)
|
||||
if action.binding_id is not None:
|
||||
binding_record = session.get(HumanInputIMBinding, str(action.binding_id))
|
||||
elif action.match_kind is MatchKind.NORMALIZED_EMAIL and action.contact_id is not None:
|
||||
identity = IMIdentity.create(
|
||||
identity_id=IMIdentityId(str(uuidv7())),
|
||||
integration_id=plan.integration_revision.integration_id,
|
||||
provider=plan.provider,
|
||||
provider_user_id=action.entry.provider_user_id,
|
||||
display_name=action.entry.display_name,
|
||||
email=action.entry.email,
|
||||
raw_payload=action.entry.raw_payload.to_mapping(),
|
||||
last_seen_sync_run_id=plan.sync_run_id,
|
||||
last_seen_at=now,
|
||||
now=now,
|
||||
)
|
||||
identity_record = identity_to_record(identity)
|
||||
session.add(identity_record)
|
||||
binding = IMBinding.create(
|
||||
binding_id=IMBindingId(str(uuidv7())),
|
||||
integration_id=plan.integration_revision.integration_id,
|
||||
scope=IMBindingScope.ORGANIZATION,
|
||||
scope_id=str(plan.integration_revision.integration_id),
|
||||
contact_id=action.contact_id,
|
||||
identity_id=identity.id,
|
||||
provider=plan.provider,
|
||||
bound_by_account_id=None,
|
||||
now=now,
|
||||
)
|
||||
binding_record = binding_to_record(binding)
|
||||
session.add(binding_record)
|
||||
|
||||
if action.contact_id is not None:
|
||||
contact_record = session.get(HumanInputContact, str(action.contact_id))
|
||||
|
||||
result_type = (
|
||||
IMSyncResultType.NOT_MATCHED
|
||||
if action.match_kind is MatchKind.UNMATCHED
|
||||
else IMSyncResultType.ADDED
|
||||
if action.match_kind is MatchKind.NORMALIZED_EMAIL
|
||||
else IMSyncResultType.SKIPPED
|
||||
)
|
||||
return SyncResultFact(
|
||||
id=IMSyncResultId(str(uuidv7())),
|
||||
integration_id=plan.integration_revision.integration_id,
|
||||
sync_run_id=plan.sync_run_id,
|
||||
result_type=result_type,
|
||||
provider_user_id=action.entry.provider_user_id,
|
||||
display_name=action.entry.display_name,
|
||||
email=action.entry.email,
|
||||
normalized_email=action.entry.normalized_email,
|
||||
contact_id=action.contact_id,
|
||||
identity_id=IMIdentityId(identity_record.id) if identity_record is not None else None,
|
||||
binding_id=IMBindingId(binding_record.id) if binding_record is not None else None,
|
||||
removal_reason=None,
|
||||
reason_code="existing_provider_identity" if result_type is IMSyncResultType.SKIPPED else None,
|
||||
reason_message=None,
|
||||
directory_entry_payload=action.entry.raw_payload,
|
||||
contact_snapshot=(
|
||||
SyncContactSnapshot(
|
||||
contact_id=ContactId(contact_record.id),
|
||||
name=contact_record.name,
|
||||
email=contact_record.email,
|
||||
avatar_file_id=contact_record.avatar_file_id,
|
||||
)
|
||||
if contact_record is not None
|
||||
else None
|
||||
),
|
||||
identity_snapshot=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _copy_entry_to_identity(
|
||||
record: HumanInputIMIdentity,
|
||||
action: ReconciliationAction,
|
||||
plan: ReconciliationPlan,
|
||||
now: UtcTimestamp,
|
||||
) -> None:
|
||||
record.provider_user_id = action.entry.provider_user_id
|
||||
record.display_name = action.entry.display_name
|
||||
record.normalized_name = action.entry.display_name.casefold() if action.entry.display_name else None
|
||||
record.email = action.entry.email
|
||||
record.normalized_email = str(action.entry.normalized_email) if action.entry.normalized_email else None
|
||||
from models.human_input_v2 import IMIdentityRawPayload
|
||||
|
||||
record.raw_payload = IMIdentityRawPayload(action.entry.raw_payload.to_mapping())
|
||||
record.last_seen_sync_run_id = str(plan.sync_run_id)
|
||||
record.last_seen_at = now.value
|
||||
record.updated_at = now.value
|
||||
|
||||
def _remove_identity(
|
||||
self,
|
||||
session: Session,
|
||||
plan: ReconciliationPlan,
|
||||
identity_id: IMIdentityId,
|
||||
now: UtcTimestamp,
|
||||
) -> tuple[SyncResultFact, ...]:
|
||||
record = session.scalar(
|
||||
select(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.id == str(identity_id),
|
||||
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
return ()
|
||||
binding_records = tuple(
|
||||
session.scalars(
|
||||
select(HumanInputIMBinding)
|
||||
.where(
|
||||
HumanInputIMBinding.integration_id == str(plan.integration_revision.integration_id),
|
||||
HumanInputIMBinding.im_identity_id == record.id,
|
||||
)
|
||||
.order_by(HumanInputIMBinding.created_at, HumanInputIMBinding.id)
|
||||
).all()
|
||||
)
|
||||
bindings_for_results: tuple[HumanInputIMBinding | None, ...] = binding_records or (None,)
|
||||
results: list[SyncResultFact] = []
|
||||
for binding in bindings_for_results:
|
||||
contact_record = session.get(HumanInputContact, binding.contact_id) if binding is not None else None
|
||||
results.append(
|
||||
SyncResultFact(
|
||||
id=IMSyncResultId(str(uuidv7())),
|
||||
integration_id=plan.integration_revision.integration_id,
|
||||
sync_run_id=plan.sync_run_id,
|
||||
result_type=IMSyncResultType.REMOVED,
|
||||
provider_user_id=record.provider_user_id,
|
||||
display_name=record.display_name,
|
||||
email=record.email,
|
||||
normalized_email=(
|
||||
NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None
|
||||
),
|
||||
contact_id=ContactId(binding.contact_id) if binding is not None else None,
|
||||
identity_id=identity_id,
|
||||
binding_id=IMBindingId(binding.id) if binding is not None else None,
|
||||
removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY,
|
||||
reason_code="not_present_in_directory",
|
||||
reason_message=None,
|
||||
directory_entry_payload=None,
|
||||
contact_snapshot=(
|
||||
SyncContactSnapshot(
|
||||
contact_id=ContactId(contact_record.id),
|
||||
name=contact_record.name,
|
||||
email=contact_record.email,
|
||||
avatar_file_id=contact_record.avatar_file_id,
|
||||
)
|
||||
if contact_record is not None
|
||||
else None
|
||||
),
|
||||
identity_snapshot=SyncIdentitySnapshot(
|
||||
identity_id=identity_id,
|
||||
provider=record.provider,
|
||||
provider_user_id=record.provider_user_id,
|
||||
display_name=record.display_name,
|
||||
email=record.email,
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
for binding in binding_records:
|
||||
session.delete(binding)
|
||||
session.delete(record)
|
||||
return tuple(results)
|
||||
|
||||
@staticmethod
|
||||
def _stale_result(plan: ReconciliationPlan, now: UtcTimestamp) -> SyncResultFact:
|
||||
return SyncResultFact(
|
||||
id=IMSyncResultId(str(uuidv7())),
|
||||
integration_id=plan.integration_revision.integration_id,
|
||||
sync_run_id=plan.sync_run_id,
|
||||
result_type=IMSyncResultType.FAILED,
|
||||
provider_user_id=None,
|
||||
display_name=None,
|
||||
email=None,
|
||||
normalized_email=None,
|
||||
contact_id=None,
|
||||
identity_id=None,
|
||||
binding_id=None,
|
||||
removal_reason=None,
|
||||
reason_code="stale_integration_revision",
|
||||
reason_message="Integration configuration changed before reconciliation apply.",
|
||||
directory_entry_payload=None,
|
||||
contact_snapshot=None,
|
||||
identity_snapshot=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _append_result_record(session: Session, result: SyncResultFact) -> None:
|
||||
session.add(sync_result_to_record(result))
|
||||
@@ -0,0 +1,31 @@
|
||||
"""SQLAlchemy persistence for Human Input v2 submission and shared audit facts."""
|
||||
|
||||
from .mappers import (
|
||||
audit_event_from_record,
|
||||
audit_event_to_record,
|
||||
proof_from_record_value,
|
||||
proof_to_record_value,
|
||||
submission_from_record,
|
||||
submission_to_record,
|
||||
)
|
||||
from .otp_audit_writer import SQLAlchemyOTPChallengeAuditWriter
|
||||
from .repository import (
|
||||
SQLAlchemySubmissionRepository,
|
||||
SQLAlchemySubmissionTransaction,
|
||||
SubmissionPersistenceError,
|
||||
SubmissionScopeNotFoundError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SQLAlchemyOTPChallengeAuditWriter",
|
||||
"SQLAlchemySubmissionRepository",
|
||||
"SQLAlchemySubmissionTransaction",
|
||||
"SubmissionPersistenceError",
|
||||
"SubmissionScopeNotFoundError",
|
||||
"audit_event_from_record",
|
||||
"audit_event_to_record",
|
||||
"proof_from_record_value",
|
||||
"proof_to_record_value",
|
||||
"submission_from_record",
|
||||
"submission_to_record",
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Explicit mappings for verified proof, actor, submission, and audit records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import assert_never
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
AccountSubmissionActor,
|
||||
ContactOTPSubject,
|
||||
EmailAddressOTPSubject,
|
||||
EmailAddressSubmissionActor,
|
||||
EmailOTPSubject,
|
||||
EndUserSubmissionActor,
|
||||
FormAuthorizationAuditEvent,
|
||||
FormAuthorizationAuditEventType,
|
||||
FormRef,
|
||||
FormSubmission,
|
||||
FrozenJSONObject,
|
||||
SubmissionActor,
|
||||
VerifiedAccountSessionProof,
|
||||
VerifiedEmailOTPProof,
|
||||
VerifiedIMIdentityProof,
|
||||
VerifiedSubmissionProof,
|
||||
VerifiedTrustedEndUserProof,
|
||||
)
|
||||
from core.human_input_v2.entities import (
|
||||
HumanInputApproverGrantSubjectType,
|
||||
HumanInputSubmissionActorType,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
ContactId,
|
||||
DeliveryEndpointId,
|
||||
EndUserId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from models.human_input_v2 import (
|
||||
AccountSessionAuthorizationProof,
|
||||
EmailOTPAuthorizationProof,
|
||||
FormAuditEventPayload,
|
||||
FormAuthorizationProof,
|
||||
FormCanonicalValues,
|
||||
FormInputSnapshot,
|
||||
HumanInputV2FormAuditEvent,
|
||||
HumanInputV2FormSubmission,
|
||||
IMIdentityAuthorizationProof,
|
||||
TrustedEndUserAuthorizationProof,
|
||||
)
|
||||
|
||||
|
||||
def _timestamp(value: datetime) -> UtcTimestamp:
|
||||
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
|
||||
|
||||
|
||||
def proof_to_record_value(proof: VerifiedSubmissionProof) -> FormAuthorizationProof:
|
||||
"""Serialize verified evidence without introducing reusable credentials."""
|
||||
|
||||
match proof:
|
||||
case VerifiedAccountSessionProof(account_id=account_id):
|
||||
return AccountSessionAuthorizationProof(account_id=str(account_id))
|
||||
case VerifiedTrustedEndUserProof(end_user_id=end_user_id, app_id=app_id):
|
||||
return TrustedEndUserAuthorizationProof(app_id=str(app_id), end_user_id=str(end_user_id))
|
||||
case VerifiedEmailOTPProof() as email_proof:
|
||||
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
|
||||
contact_id: str | None = None
|
||||
if isinstance(email_proof.subject, ContactOTPSubject):
|
||||
subject_type = HumanInputApproverGrantSubjectType.CONTACT
|
||||
contact_id = str(email_proof.subject.contact_id)
|
||||
return EmailOTPAuthorizationProof(
|
||||
otp_challenge_id=str(email_proof.challenge_ref.challenge_id),
|
||||
workspace_id=str(email_proof.challenge_ref.form_ref.workspace_id),
|
||||
form_id=str(email_proof.challenge_ref.form_ref.form_id),
|
||||
approver_grant_id=str(email_proof.challenge_ref.grant_ref.grant_id),
|
||||
subject_type=subject_type,
|
||||
contact_id=contact_id,
|
||||
verified_email=str(email_proof.normalized_email),
|
||||
verified_at=email_proof.verified_at.value,
|
||||
)
|
||||
case VerifiedIMIdentityProof() as im_proof:
|
||||
return IMIdentityAuthorizationProof(
|
||||
integration_id=str(im_proof.integration_id),
|
||||
im_identity_id=str(im_proof.identity_id),
|
||||
im_binding_id=str(im_proof.binding_id) if im_proof.binding_id is not None else None,
|
||||
provider=im_proof.provider,
|
||||
provider_tenant_id=im_proof.provider_tenant_id,
|
||||
provider_user_id=im_proof.provider_user_id,
|
||||
)
|
||||
assert_never(proof)
|
||||
|
||||
|
||||
def proof_from_record_value(
|
||||
record_value: FormAuthorizationProof,
|
||||
*,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> VerifiedSubmissionProof:
|
||||
"""Rebuild verified evidence using the owner scope from its audit record."""
|
||||
|
||||
match record_value:
|
||||
case AccountSessionAuthorizationProof(account_id=account_id):
|
||||
return VerifiedAccountSessionProof(AccountId(account_id))
|
||||
case TrustedEndUserAuthorizationProof(app_id=app_id, end_user_id=end_user_id):
|
||||
return VerifiedTrustedEndUserProof(EndUserId(end_user_id), AppId(app_id))
|
||||
case EmailOTPAuthorizationProof() as email_proof:
|
||||
if email_proof.workspace_id != str(workspace_id):
|
||||
raise ValueError("authorized Email proof owner does not match the audit event")
|
||||
normalized_email = NormalizedEmail(email_proof.verified_email)
|
||||
subject: EmailOTPSubject
|
||||
if email_proof.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
|
||||
if email_proof.contact_id is None:
|
||||
raise ValueError("contact Email proof record is missing contact_id")
|
||||
subject = ContactOTPSubject(ContactId(email_proof.contact_id))
|
||||
elif email_proof.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
|
||||
if email_proof.contact_id is not None:
|
||||
raise ValueError("EmailAddress proof record must not contain contact_id")
|
||||
subject = EmailAddressOTPSubject(normalized_email)
|
||||
else:
|
||||
raise ValueError("Email proof record has an unsupported subject type")
|
||||
challenge_ref = (
|
||||
FormRef(workspace_id, FormId(email_proof.form_id))
|
||||
.grant(ApproverGrantId(email_proof.approver_grant_id))
|
||||
.challenge(OTPChallengeId(email_proof.otp_challenge_id))
|
||||
)
|
||||
return VerifiedEmailOTPProof(
|
||||
challenge_ref=challenge_ref,
|
||||
subject=subject,
|
||||
normalized_email=normalized_email,
|
||||
verified_at=_timestamp(email_proof.verified_at),
|
||||
)
|
||||
case IMIdentityAuthorizationProof() as im_proof:
|
||||
return VerifiedIMIdentityProof(
|
||||
integration_id=IntegrationId(im_proof.integration_id),
|
||||
identity_id=IMIdentityId(im_proof.im_identity_id),
|
||||
binding_id=IMBindingId(im_proof.im_binding_id) if im_proof.im_binding_id is not None else None,
|
||||
provider=im_proof.provider,
|
||||
provider_tenant_id=im_proof.provider_tenant_id,
|
||||
provider_user_id=im_proof.provider_user_id,
|
||||
)
|
||||
assert_never(record_value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmissionActorRecordFields:
|
||||
"""Exactly one populated actor column selected by its discriminator."""
|
||||
|
||||
actor_type: HumanInputSubmissionActorType
|
||||
account_id: str | None
|
||||
end_user_id: str | None
|
||||
normalized_email: str | None
|
||||
|
||||
|
||||
def _actor_to_record_fields(actor: SubmissionActor) -> SubmissionActorRecordFields:
|
||||
match actor:
|
||||
case AccountSubmissionActor(account_id=account_id):
|
||||
return SubmissionActorRecordFields(HumanInputSubmissionActorType.ACCOUNT, str(account_id), None, None)
|
||||
case EndUserSubmissionActor(end_user_id=end_user_id):
|
||||
return SubmissionActorRecordFields(HumanInputSubmissionActorType.END_USER, None, str(end_user_id), None)
|
||||
case EmailAddressSubmissionActor(normalized_email=normalized_email):
|
||||
return SubmissionActorRecordFields(
|
||||
HumanInputSubmissionActorType.EMAIL_ADDRESS,
|
||||
None,
|
||||
None,
|
||||
str(normalized_email),
|
||||
)
|
||||
assert_never(actor)
|
||||
|
||||
|
||||
def _actor_from_record(record: HumanInputV2FormSubmission) -> SubmissionActor:
|
||||
actor_columns = (
|
||||
record.actor_account_id is not None,
|
||||
record.actor_end_user_id is not None,
|
||||
record.actor_normalized_email is not None,
|
||||
)
|
||||
if sum(actor_columns) != 1:
|
||||
raise ValueError("submission actor columns must contain exactly one identity")
|
||||
if record.actor_type is HumanInputSubmissionActorType.ACCOUNT and record.actor_account_id is not None:
|
||||
return AccountSubmissionActor(AccountId(record.actor_account_id))
|
||||
if record.actor_type is HumanInputSubmissionActorType.END_USER and record.actor_end_user_id is not None:
|
||||
return EndUserSubmissionActor(EndUserId(record.actor_end_user_id))
|
||||
if record.actor_type is HumanInputSubmissionActorType.EMAIL_ADDRESS and record.actor_normalized_email is not None:
|
||||
return EmailAddressSubmissionActor(NormalizedEmail(record.actor_normalized_email))
|
||||
raise ValueError("submission actor discriminator does not match its actor columns")
|
||||
|
||||
|
||||
def submission_to_record(submission: FormSubmission) -> HumanInputV2FormSubmission:
|
||||
"""Map one detached immutable winning submission to an ORM record."""
|
||||
|
||||
actor_fields = _actor_to_record_fields(submission.actor)
|
||||
record = HumanInputV2FormSubmission(
|
||||
tenant_id=str(submission.form_ref.workspace_id),
|
||||
form_id=str(submission.form_ref.form_id),
|
||||
approver_grant_id=str(submission.approver_grant_id),
|
||||
actor_type=actor_fields.actor_type,
|
||||
authorization_audit_event_id=str(submission.authorization_audit_event_id),
|
||||
selected_action_id=submission.selected_action_id,
|
||||
input_snapshot=FormInputSnapshot(submission.input_snapshot.to_mapping()),
|
||||
canonical_values=FormCanonicalValues(submission.canonical_values.to_mapping()),
|
||||
submitted_at=submission.submitted_at.value,
|
||||
actor_account_id=actor_fields.account_id,
|
||||
actor_end_user_id=actor_fields.end_user_id,
|
||||
actor_normalized_email=actor_fields.normalized_email,
|
||||
endpoint_id=str(submission.endpoint_id) if submission.endpoint_id is not None else None,
|
||||
)
|
||||
record.id = str(submission.id)
|
||||
record.created_at = submission.created_at.value
|
||||
record.updated_at = submission.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def submission_from_record(record: HumanInputV2FormSubmission) -> FormSubmission:
|
||||
"""Rebuild one submission while validating its actor and structured values."""
|
||||
|
||||
if not isinstance(record.input_snapshot, FormInputSnapshot) or not isinstance(
|
||||
record.canonical_values, FormCanonicalValues
|
||||
):
|
||||
raise ValueError("submission record has malformed structured values")
|
||||
return FormSubmission(
|
||||
id=SubmissionId(record.id),
|
||||
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
|
||||
approver_grant_id=ApproverGrantId(record.approver_grant_id),
|
||||
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
|
||||
authorization_audit_event_id=AuditEventId(record.authorization_audit_event_id),
|
||||
actor=_actor_from_record(record),
|
||||
selected_action_id=record.selected_action_id,
|
||||
input_snapshot=FrozenJSONObject.from_mapping(record.input_snapshot.root),
|
||||
canonical_values=FrozenJSONObject.from_mapping(record.canonical_values.root),
|
||||
submitted_at=_timestamp(record.submitted_at),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def audit_event_to_record(event: FormAuthorizationAuditEvent) -> HumanInputV2FormAuditEvent:
|
||||
"""Map one immutable shared audit fact to its append-only record."""
|
||||
|
||||
event.validate_authorization_proof_owner()
|
||||
record = HumanInputV2FormAuditEvent(
|
||||
tenant_id=str(event.form_ref.workspace_id),
|
||||
form_id=str(event.form_ref.form_id),
|
||||
event_type=event.event_type.value,
|
||||
occurred_at=event.occurred_at.value,
|
||||
approver_grant_id=str(event.approver_grant_id) if event.approver_grant_id is not None else None,
|
||||
endpoint_id=str(event.endpoint_id) if event.endpoint_id is not None else None,
|
||||
channel=event.channel,
|
||||
reason_code=event.reason_code,
|
||||
reason_message=event.reason_message,
|
||||
authorization_proof=(
|
||||
proof_to_record_value(event.authorization_proof) if event.authorization_proof is not None else None
|
||||
),
|
||||
event_payload=FormAuditEventPayload(event.payload.to_mapping()) if event.payload is not None else None,
|
||||
)
|
||||
record.id = str(event.id)
|
||||
record.created_at = event.created_at.value
|
||||
record.updated_at = event.updated_at.value
|
||||
return record
|
||||
|
||||
|
||||
def audit_event_from_record(record: HumanInputV2FormAuditEvent) -> FormAuthorizationAuditEvent:
|
||||
"""Rebuild one audit fact while enforcing authorized/rejected semantics."""
|
||||
|
||||
try:
|
||||
event_type = FormAuthorizationAuditEventType(record.event_type)
|
||||
except ValueError as error:
|
||||
raise ValueError("audit record has an unsupported event type") from error
|
||||
proof = (
|
||||
proof_from_record_value(record.authorization_proof, workspace_id=WorkspaceId(record.tenant_id))
|
||||
if record.authorization_proof is not None
|
||||
else None
|
||||
)
|
||||
if event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED and proof is None:
|
||||
raise ValueError("authorized audit record requires verified proof")
|
||||
if record.event_payload is not None and not isinstance(record.event_payload, FormAuditEventPayload):
|
||||
raise ValueError("audit record has a malformed structured payload")
|
||||
return FormAuthorizationAuditEvent(
|
||||
id=AuditEventId(record.id),
|
||||
event_type=event_type,
|
||||
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
|
||||
approver_grant_id=(ApproverGrantId(record.approver_grant_id) if record.approver_grant_id is not None else None),
|
||||
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
|
||||
channel=record.channel,
|
||||
reason_code=record.reason_code,
|
||||
reason_message=record.reason_message,
|
||||
authorization_proof=proof,
|
||||
payload=(
|
||||
FrozenJSONObject.from_mapping(record.event_payload.root) if record.event_payload is not None else None
|
||||
),
|
||||
occurred_at=_timestamp(record.occurred_at),
|
||||
created_at=_timestamp(record.created_at),
|
||||
updated_at=_timestamp(record.updated_at),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"audit_event_from_record",
|
||||
"audit_event_to_record",
|
||||
"proof_from_record_value",
|
||||
"proof_to_record_value",
|
||||
"submission_from_record",
|
||||
"submission_to_record",
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Transaction-scoped OTP issuance writer for the shared form audit table.
|
||||
|
||||
The writer only adds a mapped record to the caller-owned Session. It never
|
||||
flushes or commits, so a later OTP challenge failure rolls the audit fact back
|
||||
with the preceding proof-session transaction.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
FormAuthorizationAuditEvent,
|
||||
FormAuthorizationAuditEventType,
|
||||
FrozenJSONObject,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputDeliveryChannel
|
||||
from core.human_input_v2.shared import AuditEventId
|
||||
from repositories.human_input_v2.approval import OTPChallengeAuditFact
|
||||
|
||||
from .mappers import audit_event_to_record
|
||||
|
||||
|
||||
class SQLAlchemyOTPChallengeAuditWriter:
|
||||
"""Map one secret-free OTP issuance fact into the caller transaction."""
|
||||
|
||||
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
|
||||
"""Add the shared audit record without changing transaction ownership."""
|
||||
|
||||
event = FormAuthorizationAuditEvent(
|
||||
id=AuditEventId(fact.audit_event_id),
|
||||
event_type=FormAuthorizationAuditEventType.OTP_CHALLENGE_ISSUED,
|
||||
form_ref=fact.challenge_ref.form_ref,
|
||||
approver_grant_id=fact.challenge_ref.grant_ref.grant_id,
|
||||
endpoint_id=None,
|
||||
channel=HumanInputDeliveryChannel.EMAIL,
|
||||
reason_code=None,
|
||||
reason_message=None,
|
||||
authorization_proof=None,
|
||||
payload=FrozenJSONObject.from_mapping(
|
||||
{
|
||||
"challenge_id": str(fact.challenge_ref.challenge_id),
|
||||
"previous_challenge_id": (
|
||||
str(fact.previous_challenge_id) if fact.previous_challenge_id is not None else None
|
||||
),
|
||||
"send_count": fact.send_count,
|
||||
}
|
||||
),
|
||||
occurred_at=fact.occurred_at,
|
||||
created_at=fact.occurred_at,
|
||||
updated_at=fact.occurred_at,
|
||||
)
|
||||
session.add(audit_event_to_record(event))
|
||||
|
||||
|
||||
__all__ = ["SQLAlchemyOTPChallengeAuditWriter"]
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Form-locked SQLAlchemy adapter for Human Input v2 first-success submission.
|
||||
|
||||
One repeatable-read transaction locks the tenant-owned Form, loads the target
|
||||
grant, endpoint, and current identity facts, then keeps that immutable context
|
||||
authoritative for the write set. The commit path never reloads Contact or IM
|
||||
binding state. Audit insert, unique Submission insert, and Form transition share
|
||||
one savepoint so a unique-form race becomes a stable loser result without
|
||||
retaining its audit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
AuthorizationContext,
|
||||
AuthorizedSubmissionCommit,
|
||||
ContactApprovalSubject,
|
||||
CurrentContactAuthorizationFacts,
|
||||
CurrentEndUserAuthorizationFacts,
|
||||
CurrentIMAuthorizationFacts,
|
||||
EndUserApprovalSubject,
|
||||
FormAuthorizationAuditEvent,
|
||||
FormAuthorizationAuditEventType,
|
||||
FrozenJSONObject,
|
||||
RetryableSubmissionPersistenceError,
|
||||
SubmissionAttemptScope,
|
||||
SubmissionCommitResult,
|
||||
SubmissionCommitStatus,
|
||||
VerifiedIMIdentityProof,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputV2FormStatus, IMBindingScope
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
from models.account import Account, AccountStatus, TenantAccountJoin
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputPlatformContactWorkspaceEntry,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
)
|
||||
from models.model import EndUser
|
||||
from repositories.human_input_v2.form.mappers import endpoint_from_record, form_from_record, grant_from_record
|
||||
|
||||
from .mappers import audit_event_to_record, submission_to_record
|
||||
|
||||
_POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE = "40001"
|
||||
|
||||
|
||||
class SubmissionPersistenceError(RuntimeError):
|
||||
"""A submission transaction failed and rolled back its complete write set."""
|
||||
|
||||
|
||||
class SubmissionScopeNotFoundError(ValueError):
|
||||
"""The complete tenant/form/grant/endpoint owner chain does not exist."""
|
||||
|
||||
|
||||
class SQLAlchemySubmissionRepository:
|
||||
"""Create short session-bound transactions for the atomic submission use case."""
|
||||
|
||||
_session_maker: sessionmaker[Session]
|
||||
|
||||
def __init__(self, session_maker: sessionmaker[Session]) -> None:
|
||||
self._session_maker = session_maker
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, scope: SubmissionAttemptScope):
|
||||
"""Commit on clean exit and translate infrastructure failures after rollback."""
|
||||
|
||||
try:
|
||||
with self._session_maker() as session, session.begin():
|
||||
self._configure_snapshot_transaction(session)
|
||||
yield SQLAlchemySubmissionTransaction(session, scope)
|
||||
except SubmissionPersistenceError as error:
|
||||
if _is_postgresql_serialization_failure(error):
|
||||
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
|
||||
raise
|
||||
except SQLAlchemyError as error:
|
||||
if _is_postgresql_serialization_failure(error):
|
||||
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
|
||||
raise SubmissionPersistenceError("submission transaction failed") from error
|
||||
|
||||
@staticmethod
|
||||
def _configure_snapshot_transaction(session: Session) -> None:
|
||||
"""Establish one MVCC snapshot before the transaction's first query."""
|
||||
|
||||
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
|
||||
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
|
||||
|
||||
|
||||
class SQLAlchemySubmissionTransaction:
|
||||
"""Session-bound owner of one coherent load and at most one write outcome."""
|
||||
|
||||
_session: Session
|
||||
_scope: SubmissionAttemptScope
|
||||
_form_record: HumanInputV2Form | None
|
||||
_context: AuthorizationContext | None
|
||||
|
||||
def __init__(self, session: Session, scope: SubmissionAttemptScope) -> None:
|
||||
self._session = session
|
||||
self._scope = scope
|
||||
self._form_record = None
|
||||
self._context = None
|
||||
|
||||
@staticmethod
|
||||
def locked_form_statement(scope: SubmissionAttemptScope) -> sa.Select[tuple[HumanInputV2Form]]:
|
||||
"""Select exactly one tenant-owned Form under the first-success row lock."""
|
||||
|
||||
return (
|
||||
select(HumanInputV2Form)
|
||||
.where(
|
||||
HumanInputV2Form.tenant_id == str(scope.form_ref.workspace_id),
|
||||
HumanInputV2Form.id == str(scope.form_ref.form_id),
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
def load_authorization_context(self, *, proof: object) -> AuthorizationContext:
|
||||
"""Load all relevant current facts once through the active transaction."""
|
||||
|
||||
if self._context is not None:
|
||||
return self._context
|
||||
form_record = self._session.scalar(self.locked_form_statement(self._scope))
|
||||
if form_record is None:
|
||||
raise SubmissionScopeNotFoundError("form owner scope does not exist")
|
||||
grant_record = self._session.scalar(
|
||||
select(HumanInputV2FormApproverGrant).where(
|
||||
HumanInputV2FormApproverGrant.tenant_id == str(self._scope.form_ref.workspace_id),
|
||||
HumanInputV2FormApproverGrant.form_id == str(self._scope.form_ref.form_id),
|
||||
HumanInputV2FormApproverGrant.id == str(self._scope.approver_grant_id),
|
||||
)
|
||||
)
|
||||
if grant_record is None:
|
||||
raise SubmissionScopeNotFoundError("approver grant owner scope does not exist")
|
||||
endpoint_record = self._load_endpoint_record()
|
||||
form = form_from_record(form_record, (grant_record,))
|
||||
grant = grant_from_record(grant_record)
|
||||
endpoint = endpoint_from_record(endpoint_record) if endpoint_record is not None else None
|
||||
current_contact = self._load_current_contact(grant)
|
||||
current_end_user = self._load_current_end_user(grant, form.app_id)
|
||||
current_im = self._load_current_im_binding(proof, current_contact)
|
||||
context = AuthorizationContext(
|
||||
form=form,
|
||||
grant=grant,
|
||||
endpoint=endpoint,
|
||||
current_contact=current_contact,
|
||||
current_end_user=current_end_user,
|
||||
current_im_binding=current_im,
|
||||
)
|
||||
self._form_record = form_record
|
||||
self._context = context
|
||||
return context
|
||||
|
||||
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None:
|
||||
"""Append one scoped rejection fact without changing Form lifecycle."""
|
||||
|
||||
self._require_loaded_context()
|
||||
if event.event_type is not FormAuthorizationAuditEventType.SUBMISSION_REJECTED:
|
||||
raise ValueError("rejection append requires a submission_rejected event")
|
||||
self._validate_event_owner(event)
|
||||
try:
|
||||
self._session.add(audit_event_to_record(event))
|
||||
self._session.flush()
|
||||
except SQLAlchemyError as error:
|
||||
raise SubmissionPersistenceError("failed to append submission rejection audit") from error
|
||||
|
||||
def commit_authorized_submission_once(
|
||||
self,
|
||||
commit: AuthorizedSubmissionCommit,
|
||||
) -> SubmissionCommitResult:
|
||||
"""Atomically insert authorized audit/submission and transition the locked Form."""
|
||||
|
||||
context = self._require_loaded_context()
|
||||
form_record = self._form_record
|
||||
assert form_record is not None
|
||||
self._validate_authorized_owner(commit, context)
|
||||
if form_record.status is not HumanInputV2FormStatus.WAITING:
|
||||
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
|
||||
|
||||
occurred_at = commit.authorized.transition.decided_at
|
||||
channel = context.endpoint.channel if context.endpoint is not None else None
|
||||
audit_event = FormAuthorizationAuditEvent(
|
||||
id=commit.authorization_audit_event_id,
|
||||
event_type=FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED,
|
||||
form_ref=self._scope.form_ref,
|
||||
approver_grant_id=self._scope.approver_grant_id,
|
||||
endpoint_id=self._scope.endpoint_id,
|
||||
channel=channel,
|
||||
reason_code=None,
|
||||
reason_message=None,
|
||||
authorization_proof=commit.authorized.proof,
|
||||
payload=FrozenJSONObject.from_mapping(
|
||||
{"selected_action_id": commit.authorized.transition.selected_action_id}
|
||||
),
|
||||
occurred_at=occurred_at,
|
||||
created_at=occurred_at,
|
||||
updated_at=occurred_at,
|
||||
)
|
||||
submission = commit.to_submission(
|
||||
form_ref=self._scope.form_ref,
|
||||
approver_grant_id=self._scope.approver_grant_id,
|
||||
endpoint_id=self._scope.endpoint_id,
|
||||
submitted_at=occurred_at,
|
||||
)
|
||||
try:
|
||||
with self._session.begin_nested():
|
||||
self._session.add(audit_event_to_record(audit_event))
|
||||
self._session.flush()
|
||||
self._session.add(submission_to_record(submission))
|
||||
self._session.flush()
|
||||
form_record.status = HumanInputV2FormStatus.SUBMITTED
|
||||
form_record.updated_at = occurred_at.value
|
||||
self._session.flush()
|
||||
except IntegrityError as error:
|
||||
if self._is_form_submission_unique_conflict(error):
|
||||
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
|
||||
raise SubmissionPersistenceError("authorized submission violated a persistence invariant") from error
|
||||
except SQLAlchemyError as error:
|
||||
raise SubmissionPersistenceError("failed to commit authorized submission") from error
|
||||
return SubmissionCommitResult(SubmissionCommitStatus.COMMITTED, submission)
|
||||
|
||||
def _load_endpoint_record(self) -> HumanInputV2FormDeliveryEndpoint | None:
|
||||
if self._scope.endpoint_id is None:
|
||||
return None
|
||||
endpoint_record = self._session.scalar(
|
||||
select(HumanInputV2FormDeliveryEndpoint).where(
|
||||
HumanInputV2FormDeliveryEndpoint.tenant_id == str(self._scope.form_ref.workspace_id),
|
||||
HumanInputV2FormDeliveryEndpoint.form_id == str(self._scope.form_ref.form_id),
|
||||
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(self._scope.approver_grant_id),
|
||||
HumanInputV2FormDeliveryEndpoint.id == str(self._scope.endpoint_id),
|
||||
)
|
||||
)
|
||||
if endpoint_record is None:
|
||||
raise SubmissionScopeNotFoundError("delivery endpoint owner scope does not exist")
|
||||
return endpoint_record
|
||||
|
||||
def _load_current_contact(self, grant) -> CurrentContactAuthorizationFacts | None:
|
||||
if not isinstance(grant.subject, ContactApprovalSubject):
|
||||
return None
|
||||
workspace_id = str(self._scope.form_ref.workspace_id)
|
||||
membership_exists = sa.exists().where(
|
||||
TenantAccountJoin.tenant_id == workspace_id,
|
||||
TenantAccountJoin.account_id == HumanInputContact.account_id,
|
||||
)
|
||||
platform_exists = sa.exists().where(
|
||||
HumanInputPlatformContactWorkspaceEntry.tenant_id == workspace_id,
|
||||
HumanInputPlatformContactWorkspaceEntry.contact_id == HumanInputContact.id,
|
||||
)
|
||||
row = self._session.execute(
|
||||
select(HumanInputContact, Account.status, membership_exists, platform_exists)
|
||||
.outerjoin(Account, Account.id == HumanInputContact.account_id)
|
||||
.where(
|
||||
HumanInputContact.id == str(grant.subject.contact_id),
|
||||
sa.or_(HumanInputContact.tenant_id == workspace_id, HumanInputContact.tenant_id.is_(None)),
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
contact_record, account_status, has_membership, has_platform_entry = row
|
||||
identity_source = contact_record.identity_source
|
||||
if identity_source is HumanInputContactIdentitySource.EXTERNAL:
|
||||
workspace_available = contact_record.tenant_id == workspace_id
|
||||
elif identity_source is HumanInputContactIdentitySource.WORKSPACE_MEMBER:
|
||||
workspace_available = contact_record.tenant_id == workspace_id and has_membership
|
||||
else:
|
||||
workspace_available = has_membership or has_platform_entry
|
||||
normalized_email = (
|
||||
NormalizedEmail(contact_record.normalized_email) if contact_record.normalized_email is not None else None
|
||||
)
|
||||
return CurrentContactAuthorizationFacts(
|
||||
contact_id=ContactId(contact_record.id),
|
||||
account_id=AccountId(contact_record.account_id) if contact_record.account_id is not None else None,
|
||||
normalized_email=normalized_email,
|
||||
account_active=contact_record.account_id is None or account_status is AccountStatus.ACTIVE,
|
||||
workspace_available=bool(workspace_available),
|
||||
)
|
||||
|
||||
def _load_current_end_user(self, grant, app_id: AppId) -> CurrentEndUserAuthorizationFacts | None:
|
||||
if not isinstance(grant.subject, EndUserApprovalSubject):
|
||||
return None
|
||||
end_user_id = str(grant.subject.end_user_id)
|
||||
workspace_id = str(self._scope.form_ref.workspace_id)
|
||||
row = self._session.execute(
|
||||
select(EndUser.id, EndUser.app_id).where(
|
||||
EndUser.id == end_user_id,
|
||||
EndUser.tenant_id == workspace_id,
|
||||
EndUser.app_id == str(app_id),
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
return CurrentEndUserAuthorizationFacts(
|
||||
end_user_id=EndUserId(row.id),
|
||||
app_id=AppId(row.app_id),
|
||||
workspace_available=True,
|
||||
)
|
||||
|
||||
def _load_current_im_binding(
|
||||
self,
|
||||
proof: object,
|
||||
current_contact: CurrentContactAuthorizationFacts | None,
|
||||
) -> CurrentIMAuthorizationFacts | None:
|
||||
if not isinstance(proof, VerifiedIMIdentityProof) or current_contact is None:
|
||||
return None
|
||||
workspace_id = str(self._scope.form_ref.workspace_id)
|
||||
integration = self._session.scalar(
|
||||
select(HumanInputIMIntegration).where(
|
||||
HumanInputIMIntegration.id == str(proof.integration_id),
|
||||
HumanInputIMIntegration.provider == proof.provider,
|
||||
sa.or_(HumanInputIMIntegration.tenant_id == workspace_id, HumanInputIMIntegration.tenant_id.is_(None)),
|
||||
)
|
||||
)
|
||||
if integration is None:
|
||||
return None
|
||||
priority = sa.case((HumanInputIMBinding.scope == IMBindingScope.WORKSPACE, 0), else_=1)
|
||||
binding_row = self._session.execute(
|
||||
select(HumanInputIMBinding, HumanInputIMIdentity)
|
||||
.outerjoin(
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIdentity.id == HumanInputIMBinding.im_identity_id,
|
||||
)
|
||||
.where(
|
||||
HumanInputIMBinding.integration_id == integration.id,
|
||||
HumanInputIMBinding.provider == proof.provider,
|
||||
HumanInputIMBinding.contact_id == str(current_contact.contact_id),
|
||||
sa.or_(
|
||||
sa.and_(
|
||||
HumanInputIMBinding.scope == IMBindingScope.WORKSPACE,
|
||||
HumanInputIMBinding.scope_id == workspace_id,
|
||||
),
|
||||
sa.and_(
|
||||
HumanInputIMBinding.scope == IMBindingScope.ORGANIZATION,
|
||||
HumanInputIMBinding.scope_id == integration.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(priority, HumanInputIMBinding.id)
|
||||
.limit(1)
|
||||
).one_or_none()
|
||||
binding_id: IMBindingId | None = None
|
||||
if binding_row is not None:
|
||||
binding_record, identity_record = binding_row
|
||||
if binding_record.integration_id != integration.id or binding_record.provider is not proof.provider:
|
||||
return None
|
||||
if (
|
||||
identity_record is None
|
||||
or identity_record.integration_id != integration.id
|
||||
or identity_record.provider is not proof.provider
|
||||
):
|
||||
return None
|
||||
binding_id = IMBindingId(binding_record.id)
|
||||
elif current_contact.normalized_email is not None:
|
||||
identity_record = self._session.scalar(
|
||||
select(HumanInputIMIdentity).where(
|
||||
HumanInputIMIdentity.integration_id == integration.id,
|
||||
HumanInputIMIdentity.provider == proof.provider,
|
||||
HumanInputIMIdentity.normalized_email == str(current_contact.normalized_email),
|
||||
)
|
||||
)
|
||||
if identity_record is None:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
return CurrentIMAuthorizationFacts(
|
||||
integration_id=IntegrationId(integration.id),
|
||||
provider=integration.provider,
|
||||
provider_tenant_id=integration.provider_tenant_id,
|
||||
contact_id=current_contact.contact_id,
|
||||
account_id=current_contact.account_id,
|
||||
identity_id=IMIdentityId(identity_record.id),
|
||||
binding_id=binding_id,
|
||||
provider_user_id=identity_record.provider_user_id,
|
||||
)
|
||||
|
||||
def _require_loaded_context(self) -> AuthorizationContext:
|
||||
if self._context is None:
|
||||
raise RuntimeError("authorization context must be loaded before persistence")
|
||||
return self._context
|
||||
|
||||
def _validate_event_owner(self, event: FormAuthorizationAuditEvent) -> None:
|
||||
if (
|
||||
event.form_ref != self._scope.form_ref
|
||||
or event.approver_grant_id != self._scope.approver_grant_id
|
||||
or event.endpoint_id != self._scope.endpoint_id
|
||||
):
|
||||
raise ValueError("audit event does not match the loaded owner scope")
|
||||
|
||||
def _validate_authorized_owner(
|
||||
self,
|
||||
commit: AuthorizedSubmissionCommit,
|
||||
context: AuthorizationContext,
|
||||
) -> None:
|
||||
transition = commit.authorized.transition
|
||||
if (
|
||||
transition.form_ref != self._scope.form_ref
|
||||
or transition.grant_id != self._scope.approver_grant_id
|
||||
or commit.authorized.endpoint_ref != (context.endpoint.ref if context.endpoint is not None else None)
|
||||
):
|
||||
raise ValueError("authorized submission does not match the loaded owner scope")
|
||||
|
||||
@staticmethod
|
||||
def _is_form_submission_unique_conflict(error: IntegrityError) -> bool:
|
||||
message = str(error.orig).lower()
|
||||
return "hiv2_form_submissions_form_uq" in message or "human_input_v2_form_submissions.form_id" in message
|
||||
|
||||
|
||||
def _is_postgresql_serialization_failure(error: BaseException) -> bool:
|
||||
"""Inspect SQLAlchemy and public driver exception links for SQLSTATE 40001."""
|
||||
|
||||
pending: list[object] = [error]
|
||||
visited: set[int] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
current_id = id(current)
|
||||
if current_id in visited:
|
||||
continue
|
||||
visited.add(current_id)
|
||||
if _structured_sqlstate(current) == _POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE:
|
||||
return True
|
||||
if isinstance(current, DBAPIError):
|
||||
pending.append(current.orig)
|
||||
if isinstance(current, BaseException):
|
||||
if current.__cause__ is not None:
|
||||
pending.append(current.__cause__)
|
||||
if current.__context__ is not None:
|
||||
pending.append(current.__context__)
|
||||
return False
|
||||
|
||||
|
||||
def _structured_sqlstate(error: object) -> str | None:
|
||||
"""Read documented psycopg/psycopg2 SQLSTATE attributes at the driver boundary."""
|
||||
|
||||
for attribute_name in ("sqlstate", "pgcode"):
|
||||
raw_code = getattr(error, attribute_name, None)
|
||||
if isinstance(raw_code, str):
|
||||
return raw_code
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SQLAlchemySubmissionRepository",
|
||||
"SQLAlchemySubmissionTransaction",
|
||||
"SubmissionPersistenceError",
|
||||
"SubmissionScopeNotFoundError",
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Providers for delivering human input forms through configured channels.
|
||||
|
||||
Dispatch has two explicit phases: load immutable DTOs while a SQLAlchemy session
|
||||
is active, then perform provider I/O after the session context has closed.
|
||||
Providers must not depend on live ORM instances because email and future IM
|
||||
clients can block or retry independently from database transaction lifetimes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.workflow.human_input_adapter import (
|
||||
DeliveryMethodType,
|
||||
EmailDeliveryConfig,
|
||||
EmailDeliveryMethod,
|
||||
InstantMessageDeliveryMethod,
|
||||
)
|
||||
from extensions.ext_mail import mail
|
||||
from graphon.runtime import VariablePool
|
||||
from models.human_input import (
|
||||
EmailExternalRecipientPayload,
|
||||
EmailMemberRecipientPayload,
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
InstantMessageRecipientPayload,
|
||||
RecipientPayload,
|
||||
RecipientType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RECIPIENT_PAYLOAD_ADAPTER = TypeAdapter(RecipientPayload)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HumanInputFormDeliveryRecipientContext:
|
||||
recipient_id: str
|
||||
recipient_type: RecipientType
|
||||
recipient_payload: str
|
||||
access_token: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HumanInputFormDeliveryContext:
|
||||
form_id: str
|
||||
tenant_id: str
|
||||
app_id: str
|
||||
workflow_run_id: str | None
|
||||
rendered_content: str
|
||||
delivery_id: str
|
||||
delivery_method_type: DeliveryMethodType
|
||||
delivery_config_id: str | None
|
||||
channel_payload: str
|
||||
recipients: tuple[HumanInputFormDeliveryRecipientContext, ...]
|
||||
variable_pool: VariablePool | None = None
|
||||
|
||||
|
||||
class HumanInputFormDeliveryProvider(Protocol):
|
||||
"""Sends a persisted human input form through one delivery method."""
|
||||
|
||||
delivery_method_type: DeliveryMethodType
|
||||
|
||||
def send(self, *, context: HumanInputFormDeliveryContext) -> None: ...
|
||||
|
||||
|
||||
class HumanInputFormMailClient(Protocol):
|
||||
def send(self, *, to: str, subject: str, html: str) -> None: ...
|
||||
|
||||
|
||||
class HumanInputFormDeliveryProviderRegistry:
|
||||
_providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider]
|
||||
|
||||
def __init__(self, providers: Sequence[HumanInputFormDeliveryProvider] | None = None) -> None:
|
||||
self._providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider] = {}
|
||||
for provider in providers or ():
|
||||
self.register(provider)
|
||||
|
||||
def register(self, provider: HumanInputFormDeliveryProvider) -> None:
|
||||
self._providers[provider.delivery_method_type] = provider
|
||||
|
||||
def dispatch(self, *, context: HumanInputFormDeliveryContext, continue_on_error: bool = True) -> bool:
|
||||
"""Dispatch one loaded context through its provider.
|
||||
|
||||
Provider calls are external I/O, so callers default to isolating send failures per context.
|
||||
Loading and DTO construction stay outside this boundary and should fail through their caller.
|
||||
"""
|
||||
provider = self._providers.get(context.delivery_method_type)
|
||||
if provider is None:
|
||||
logger.warning(
|
||||
"No human input form delivery provider registered, form_id=%s, delivery_id=%s, method=%s",
|
||||
context.form_id,
|
||||
context.delivery_id,
|
||||
context.delivery_method_type,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
provider.send(context=context)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to dispatch human input form delivery, form_id=%s, delivery_id=%s, method=%s",
|
||||
context.form_id,
|
||||
context.delivery_id,
|
||||
context.delivery_method_type,
|
||||
)
|
||||
if not continue_on_error:
|
||||
raise
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def default(cls, *, mail_client: HumanInputFormMailClient = mail) -> HumanInputFormDeliveryProviderRegistry:
|
||||
return cls(
|
||||
[
|
||||
EmailHumanInputFormDeliveryProvider(mail_client=mail_client),
|
||||
UnsupportedInstantMessageHumanInputFormDeliveryProvider(),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class HumanInputFormDeliveryDispatcher:
|
||||
_registry: HumanInputFormDeliveryProviderRegistry
|
||||
|
||||
def __init__(self, registry: HumanInputFormDeliveryProviderRegistry | None = None) -> None:
|
||||
self._registry = registry or HumanInputFormDeliveryProviderRegistry.default()
|
||||
|
||||
def load_form_contexts(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
form: HumanInputForm,
|
||||
variable_pool: VariablePool | None = None,
|
||||
delivery_method_types: Sequence[DeliveryMethodType] | None = None,
|
||||
) -> tuple[HumanInputFormDeliveryContext, ...]:
|
||||
stmt = select(HumanInputDelivery).where(HumanInputDelivery.form_id == form.id)
|
||||
if delivery_method_types:
|
||||
stmt = stmt.where(HumanInputDelivery.delivery_method_type.in_(delivery_method_types))
|
||||
|
||||
deliveries = session.scalars(stmt).all()
|
||||
contexts: list[HumanInputFormDeliveryContext] = []
|
||||
for delivery in deliveries:
|
||||
recipients = session.scalars(
|
||||
select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id),
|
||||
).all()
|
||||
contexts.append(
|
||||
_build_delivery_context(
|
||||
form=form,
|
||||
delivery=delivery,
|
||||
recipients=recipients,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
)
|
||||
return tuple(contexts)
|
||||
|
||||
def dispatch_contexts(
|
||||
self,
|
||||
contexts: Sequence[HumanInputFormDeliveryContext],
|
||||
*,
|
||||
continue_on_error: bool = True,
|
||||
) -> None:
|
||||
for context in contexts:
|
||||
self._registry.dispatch(context=context, continue_on_error=continue_on_error)
|
||||
|
||||
|
||||
def _build_delivery_context(
|
||||
*,
|
||||
form: HumanInputForm,
|
||||
delivery: HumanInputDelivery,
|
||||
recipients: Sequence[HumanInputFormRecipient],
|
||||
variable_pool: VariablePool | None,
|
||||
) -> HumanInputFormDeliveryContext:
|
||||
return HumanInputFormDeliveryContext(
|
||||
form_id=form.id,
|
||||
tenant_id=form.tenant_id,
|
||||
app_id=form.app_id,
|
||||
workflow_run_id=form.workflow_run_id,
|
||||
rendered_content=form.rendered_content,
|
||||
delivery_id=delivery.id,
|
||||
delivery_method_type=delivery.delivery_method_type,
|
||||
delivery_config_id=delivery.delivery_config_id,
|
||||
channel_payload=delivery.channel_payload,
|
||||
recipients=tuple(
|
||||
HumanInputFormDeliveryRecipientContext(
|
||||
recipient_id=recipient.id,
|
||||
recipient_type=recipient.recipient_type,
|
||||
recipient_payload=recipient.recipient_payload,
|
||||
access_token=recipient.access_token,
|
||||
)
|
||||
for recipient in recipients
|
||||
),
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmailRecipient:
|
||||
email: str
|
||||
token: str
|
||||
|
||||
|
||||
class EmailHumanInputFormDeliveryProvider:
|
||||
_mail: HumanInputFormMailClient
|
||||
|
||||
delivery_method_type = DeliveryMethodType.EMAIL
|
||||
|
||||
def __init__(self, *, mail_client: HumanInputFormMailClient = mail) -> None:
|
||||
self._mail = mail_client
|
||||
|
||||
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
|
||||
try:
|
||||
delivery_method = EmailDeliveryMethod.model_validate_json(context.channel_payload)
|
||||
except ValidationError:
|
||||
# Persisted payloads can be stale or manually corrupted; skip only this delivery.
|
||||
logger.warning(
|
||||
"Invalid human input email delivery payload, form_id=%s, delivery_id=%s",
|
||||
context.form_id,
|
||||
context.delivery_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
recipients = self._resolve_recipients(context.recipients)
|
||||
if not recipients:
|
||||
return
|
||||
|
||||
subject = EmailDeliveryConfig.sanitize_subject(delivery_method.config.subject)
|
||||
for recipient in recipients:
|
||||
form_link = _build_form_link(recipient.token)
|
||||
body = _render_body(
|
||||
delivery_method.config.body,
|
||||
form_link,
|
||||
variable_pool=context.variable_pool,
|
||||
)
|
||||
self._mail.send(
|
||||
to=recipient.email,
|
||||
subject=subject,
|
||||
html=body,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_recipients(recipients: Sequence[HumanInputFormDeliveryRecipientContext]) -> list[_EmailRecipient]:
|
||||
resolved: list[_EmailRecipient] = []
|
||||
for recipient in recipients:
|
||||
payload = _parse_email_recipient_payload(recipient)
|
||||
if payload is None:
|
||||
continue
|
||||
token = recipient.access_token
|
||||
if not token:
|
||||
continue
|
||||
resolved.append(_EmailRecipient(email=payload.email, token=token))
|
||||
return resolved
|
||||
|
||||
|
||||
class UnsupportedInstantMessageHumanInputFormDeliveryProvider:
|
||||
delivery_method_type = DeliveryMethodType.IM
|
||||
|
||||
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
|
||||
try:
|
||||
InstantMessageDeliveryMethod.model_validate_json(context.channel_payload)
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Invalid human input instant message delivery payload, form_id=%s, delivery_id=%s",
|
||||
context.form_id,
|
||||
context.delivery_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
|
||||
valid_recipients = 0
|
||||
for recipient in context.recipients:
|
||||
if _parse_instant_message_recipient_payload(recipient) is not None:
|
||||
valid_recipients += 1
|
||||
logger.warning(
|
||||
"Human input instant message delivery is not implemented, form_id=%s, delivery_id=%s, recipients=%s",
|
||||
context.form_id,
|
||||
context.delivery_id,
|
||||
valid_recipients,
|
||||
)
|
||||
|
||||
|
||||
def _build_form_link(token: str) -> str:
|
||||
base_url = dify_config.APP_WEB_URL
|
||||
return f"{base_url.rstrip('/')}/form/{token}"
|
||||
|
||||
|
||||
def _parse_email_recipient_payload(
|
||||
recipient: HumanInputFormDeliveryRecipientContext,
|
||||
) -> EmailMemberRecipientPayload | EmailExternalRecipientPayload | None:
|
||||
try:
|
||||
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
|
||||
except ValidationError:
|
||||
# Recipient payloads are persisted per delivery; a bad row should not block other recipients.
|
||||
logger.warning(
|
||||
"Invalid human input recipient payload, recipient_id=%s",
|
||||
recipient.recipient_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
match payload:
|
||||
case EmailMemberRecipientPayload() | EmailExternalRecipientPayload():
|
||||
if payload.email:
|
||||
return payload
|
||||
return None
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_instant_message_recipient_payload(
|
||||
recipient: HumanInputFormDeliveryRecipientContext,
|
||||
) -> InstantMessageRecipientPayload | None:
|
||||
try:
|
||||
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
|
||||
except ValidationError:
|
||||
logger.warning(
|
||||
"Invalid human input instant message recipient payload, recipient_id=%s",
|
||||
recipient.recipient_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
if isinstance(payload, InstantMessageRecipientPayload):
|
||||
return payload
|
||||
return None
|
||||
|
||||
|
||||
def _render_body(
|
||||
body_template: str,
|
||||
form_link: str,
|
||||
*,
|
||||
variable_pool: VariablePool | None,
|
||||
) -> str:
|
||||
body = EmailDeliveryConfig.render_body_template(
|
||||
body=body_template,
|
||||
url=form_link,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
return EmailDeliveryConfig.render_markdown_body(body)
|
||||
@@ -0,0 +1 @@
|
||||
"""Application services for Human Input v2 use cases."""
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Application orchestration for authorized Human Input v2 submissions.
|
||||
|
||||
The handler keeps authorization and persistence behind their deep domain and
|
||||
repository interfaces. Its only application-layer policy is the post-commit
|
||||
boundary: a winning runtime submission is committed before workflow resume is
|
||||
requested, and a known enqueue failure never changes the persisted outcome.
|
||||
Retryable transaction conflicts restart the complete use case with a fresh
|
||||
snapshot; partial authorization or persistence steps are never retried alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
AuthorizedSubmissionCommit,
|
||||
FormAuthorizationAuditEvent,
|
||||
FormAuthorizationAuditEventType,
|
||||
FormSubmission,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
RetryableSubmissionPersistenceError,
|
||||
SubmissionAttemptScope,
|
||||
SubmissionAuthorizationRejection,
|
||||
SubmissionAuthorizer,
|
||||
SubmissionCommitStatus,
|
||||
SubmissionRepository,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputV2FormKind
|
||||
from core.human_input_v2.shared import AuditEventId, FormId, SubmissionId, UtcTimestamp, WorkspaceId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_SUBMISSION_TRANSACTION_RETRIES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowResumeIdentity:
|
||||
"""Stable workflow owner identity used by an idempotent resume adapter."""
|
||||
|
||||
workspace_id: WorkspaceId
|
||||
form_id: FormId
|
||||
workflow_pause_id: str
|
||||
node_execution_id: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.workflow_pause_id.strip() or not self.node_execution_id.strip():
|
||||
raise ValueError("workflow resume identity values must not be blank")
|
||||
|
||||
|
||||
class WorkflowResumeEnqueueError(RuntimeError):
|
||||
"""A resume adapter could not accept an idempotent enqueue request."""
|
||||
|
||||
|
||||
class WorkflowResumePort(Protocol):
|
||||
"""Enqueue workflow resume once for a stable form/workflow identity."""
|
||||
|
||||
def enqueue_once(self, identity: WorkflowResumeIdentity) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmitFormCommand:
|
||||
"""Verified proof and caller-owned record identities for one submission attempt."""
|
||||
|
||||
scope: SubmissionAttemptScope
|
||||
proof: object
|
||||
selected_action_id: str
|
||||
input_snapshot: FrozenJSONObject
|
||||
canonical_values: FrozenJSONObject
|
||||
submission_id: SubmissionId
|
||||
authorization_audit_event_id: AuditEventId
|
||||
rejection_audit_event_id: AuditEventId
|
||||
resume_identity: WorkflowResumeIdentity | None
|
||||
now: UtcTimestamp
|
||||
|
||||
|
||||
class SubmitFormResultStatus(StrEnum):
|
||||
"""Stable application outcomes independent of transport status codes."""
|
||||
|
||||
SUBMITTED = "submitted"
|
||||
ALREADY_COMPLETED = "already_completed"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SubmitFormResult:
|
||||
"""Submission outcome plus post-commit resume delivery state."""
|
||||
|
||||
status: SubmitFormResultStatus
|
||||
submission: FormSubmission | None
|
||||
rejection: SubmissionAuthorizationRejection | None
|
||||
resume_enqueued: bool
|
||||
|
||||
|
||||
class SubmitHumanInputFormHandler:
|
||||
"""Authorize and persist once, retrying only complete transient transactions."""
|
||||
|
||||
def __init__(self, repository: SubmissionRepository, resume_port: WorkflowResumePort) -> None:
|
||||
self._repository = repository
|
||||
self._resume_port = resume_port
|
||||
|
||||
def handle(self, command: SubmitFormCommand) -> SubmitFormResult:
|
||||
"""Return a stable outcome while preserving commit-before-enqueue ordering."""
|
||||
|
||||
identity = self._prevalidate_resume_identity(command)
|
||||
transaction_result = self._handle_transaction_with_retry(command, identity)
|
||||
if transaction_result.status is not SubmitFormResultStatus.SUBMITTED:
|
||||
return transaction_result
|
||||
|
||||
try:
|
||||
self._resume_port.enqueue_once(identity)
|
||||
except WorkflowResumeEnqueueError:
|
||||
logger.exception(
|
||||
"Failed to enqueue Human Input v2 workflow resume after submission commit: "
|
||||
"workspace_id=%s form_id=%s workflow_pause_id=%s node_execution_id=%s",
|
||||
identity.workspace_id,
|
||||
identity.form_id,
|
||||
identity.workflow_pause_id,
|
||||
identity.node_execution_id,
|
||||
)
|
||||
return SubmitFormResult(
|
||||
SubmitFormResultStatus.SUBMITTED,
|
||||
transaction_result.submission,
|
||||
None,
|
||||
False,
|
||||
)
|
||||
return SubmitFormResult(
|
||||
SubmitFormResultStatus.SUBMITTED,
|
||||
transaction_result.submission,
|
||||
None,
|
||||
True,
|
||||
)
|
||||
|
||||
def _handle_transaction_with_retry(
|
||||
self,
|
||||
command: SubmitFormCommand,
|
||||
identity: WorkflowResumeIdentity,
|
||||
) -> SubmitFormResult:
|
||||
for retry_count in range(_MAX_SUBMISSION_TRANSACTION_RETRIES + 1):
|
||||
try:
|
||||
return self._handle_transaction_once(command, identity)
|
||||
except RetryableSubmissionPersistenceError:
|
||||
if retry_count == _MAX_SUBMISSION_TRANSACTION_RETRIES:
|
||||
raise
|
||||
logger.warning(
|
||||
"Retrying Human Input v2 submission after transaction serialization failure: "
|
||||
"workspace_id=%s form_id=%s retry_count=%s",
|
||||
identity.workspace_id,
|
||||
identity.form_id,
|
||||
retry_count + 1,
|
||||
)
|
||||
raise AssertionError("submission transaction retry loop must return or raise")
|
||||
|
||||
def _handle_transaction_once(
|
||||
self,
|
||||
command: SubmitFormCommand,
|
||||
identity: WorkflowResumeIdentity,
|
||||
) -> SubmitFormResult:
|
||||
"""Run one complete load, authorization, Form decision, and commit attempt."""
|
||||
|
||||
with self._repository.transaction(command.scope) as transaction:
|
||||
context = transaction.load_authorization_context(proof=command.proof)
|
||||
self._validate_runtime_form_identity(context.form, identity)
|
||||
decision = SubmissionAuthorizer.authorize(
|
||||
context=context,
|
||||
proof=command.proof,
|
||||
selected_action_id=command.selected_action_id,
|
||||
now=command.now,
|
||||
)
|
||||
if decision.rejection is not None:
|
||||
rejection = decision.rejection
|
||||
if rejection is SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED:
|
||||
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
|
||||
transaction.append_rejection_audit(
|
||||
FormAuthorizationAuditEvent(
|
||||
id=command.rejection_audit_event_id,
|
||||
event_type=FormAuthorizationAuditEventType.SUBMISSION_REJECTED,
|
||||
form_ref=command.scope.form_ref,
|
||||
approver_grant_id=command.scope.approver_grant_id,
|
||||
endpoint_id=command.scope.endpoint_id,
|
||||
channel=context.endpoint.channel if context.endpoint is not None else None,
|
||||
reason_code=rejection,
|
||||
reason_message=None,
|
||||
authorization_proof=None,
|
||||
payload=FrozenJSONObject.from_mapping({"selected_action_id": command.selected_action_id}),
|
||||
occurred_at=command.now,
|
||||
created_at=command.now,
|
||||
updated_at=command.now,
|
||||
)
|
||||
)
|
||||
return SubmitFormResult(SubmitFormResultStatus.REJECTED, None, rejection, False)
|
||||
|
||||
authorized = decision.authorized
|
||||
assert authorized is not None
|
||||
commit_result = transaction.commit_authorized_submission_once(
|
||||
AuthorizedSubmissionCommit(
|
||||
submission_id=command.submission_id,
|
||||
authorization_audit_event_id=command.authorization_audit_event_id,
|
||||
authorized=authorized,
|
||||
input_snapshot=command.input_snapshot,
|
||||
canonical_values=command.canonical_values,
|
||||
)
|
||||
)
|
||||
if commit_result.status is SubmissionCommitStatus.ALREADY_COMPLETED:
|
||||
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
|
||||
return SubmitFormResult(SubmitFormResultStatus.SUBMITTED, commit_result.submission, None, False)
|
||||
|
||||
@staticmethod
|
||||
def _prevalidate_resume_identity(command: SubmitFormCommand) -> WorkflowResumeIdentity:
|
||||
identity = command.resume_identity
|
||||
if identity is None:
|
||||
raise ValueError("runtime resume identity is required before submission persistence")
|
||||
if (
|
||||
identity.workspace_id != command.scope.form_ref.workspace_id
|
||||
or identity.form_id != command.scope.form_ref.form_id
|
||||
):
|
||||
raise ValueError("runtime resume identity does not match the submission form owner")
|
||||
return identity
|
||||
|
||||
@staticmethod
|
||||
def _validate_runtime_form_identity(form: HumanInputForm, identity: WorkflowResumeIdentity) -> None:
|
||||
if form.kind is not HumanInputV2FormKind.RUNTIME:
|
||||
raise ValueError("submission handler accepts runtime forms only")
|
||||
if form.workflow_pause_id != identity.workflow_pause_id or form.node_execution_id != identity.node_execution_id:
|
||||
raise ValueError("runtime resume identity does not match the loaded form")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SubmitFormCommand",
|
||||
"SubmitFormResult",
|
||||
"SubmitFormResultStatus",
|
||||
"SubmitHumanInputFormHandler",
|
||||
"WorkflowResumeEnqueueError",
|
||||
"WorkflowResumeIdentity",
|
||||
"WorkflowResumePort",
|
||||
]
|
||||
@@ -1,117 +1,41 @@
|
||||
import json
|
||||
"""Celery entrypoints for delivering persisted human input forms.
|
||||
|
||||
The generic form-delivery task must stay independent from email availability so
|
||||
non-email providers can run even when mail is not configured. The legacy email
|
||||
task keeps its historical mail and feature gates for callers that still need
|
||||
email-only semantics. The generic task only checks email availability after
|
||||
loading persisted delivery contexts and only when an email context is present.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from collections.abc import Sequence
|
||||
|
||||
import click
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
|
||||
from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailDeliveryMethod
|
||||
from core.workflow.human_input_adapter import DeliveryMethodType
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_mail import mail
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.human_input import (
|
||||
DeliveryMethodType,
|
||||
HumanInputDelivery,
|
||||
HumanInputForm,
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
)
|
||||
from models.human_input import HumanInputForm
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.feature_service import FeatureService
|
||||
from services.human_input_form_delivery_provider import (
|
||||
HumanInputFormDeliveryContext,
|
||||
HumanInputFormDeliveryDispatcher,
|
||||
HumanInputFormDeliveryProviderRegistry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmailRecipient:
|
||||
email: str
|
||||
token: str
|
||||
FORM_DELIVERY_METHOD_TYPES = (DeliveryMethodType.EMAIL, DeliveryMethodType.IM)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmailDeliveryJob:
|
||||
form_id: str
|
||||
subject: str
|
||||
body: str
|
||||
form_content: str
|
||||
recipients: list[_EmailRecipient]
|
||||
|
||||
|
||||
def _build_form_link(token: str) -> str:
|
||||
base_url = dify_config.APP_WEB_URL
|
||||
return f"{base_url.rstrip('/')}/form/{token}"
|
||||
|
||||
|
||||
def _parse_recipient_payload(payload: str) -> tuple[str | None, RecipientType | None]:
|
||||
try:
|
||||
payload_dict: dict[str, Any] = json.loads(payload)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse recipient payload")
|
||||
return None, None
|
||||
|
||||
return payload_dict.get("email"), payload_dict.get("TYPE")
|
||||
|
||||
|
||||
def _load_email_jobs(session: Session, form: HumanInputForm) -> list[_EmailDeliveryJob]:
|
||||
deliveries = session.scalars(
|
||||
select(HumanInputDelivery).where(
|
||||
HumanInputDelivery.form_id == form.id,
|
||||
HumanInputDelivery.delivery_method_type == DeliveryMethodType.EMAIL,
|
||||
)
|
||||
).all()
|
||||
jobs: list[_EmailDeliveryJob] = []
|
||||
for delivery in deliveries:
|
||||
delivery_config = EmailDeliveryMethod.model_validate_json(delivery.channel_payload)
|
||||
|
||||
recipients = session.scalars(
|
||||
select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id)
|
||||
).all()
|
||||
|
||||
recipient_entities: list[_EmailRecipient] = []
|
||||
for recipient in recipients:
|
||||
email, recipient_type = _parse_recipient_payload(recipient.recipient_payload)
|
||||
if recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}:
|
||||
continue
|
||||
if not email:
|
||||
continue
|
||||
token = recipient.access_token
|
||||
if not token:
|
||||
continue
|
||||
recipient_entities.append(_EmailRecipient(email=email, token=token))
|
||||
|
||||
if not recipient_entities:
|
||||
continue
|
||||
|
||||
jobs.append(
|
||||
_EmailDeliveryJob(
|
||||
form_id=form.id,
|
||||
subject=delivery_config.config.subject,
|
||||
body=delivery_config.config.body,
|
||||
form_content=form.rendered_content,
|
||||
recipients=recipient_entities,
|
||||
)
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def _render_body(
|
||||
body_template: str,
|
||||
form_link: str,
|
||||
*,
|
||||
variable_pool: VariablePool | None,
|
||||
) -> str:
|
||||
body = EmailDeliveryConfig.render_body_template(
|
||||
body=body_template,
|
||||
url=form_link,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
return EmailDeliveryConfig.render_markdown_body(body)
|
||||
def _has_email_context(contexts: Sequence[HumanInputFormDeliveryContext]) -> bool:
|
||||
return any(context.delivery_method_type == DeliveryMethodType.EMAIL for context in contexts)
|
||||
|
||||
|
||||
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
|
||||
@@ -143,12 +67,62 @@ def _open_session(session_factory: sessionmaker | Session | None):
|
||||
return session_factory()
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None):
|
||||
if not mail.is_inited():
|
||||
def _filter_unavailable_email_contexts(
|
||||
*,
|
||||
contexts: Sequence[HumanInputFormDeliveryContext],
|
||||
tenant_id: str,
|
||||
form_id: str,
|
||||
) -> tuple[HumanInputFormDeliveryContext, ...]:
|
||||
contexts_tuple = tuple(contexts)
|
||||
if not _has_email_context(contexts_tuple):
|
||||
return contexts_tuple
|
||||
|
||||
email_delivery_enabled = False
|
||||
if mail.is_inited():
|
||||
try:
|
||||
features = FeatureService.get_features(tenant_id, exclude_vector_space=True)
|
||||
email_delivery_enabled = features.human_input_email_delivery_enabled
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to check human input email delivery availability; skipping email contexts, "
|
||||
"tenant=%s, form_id=%s",
|
||||
tenant_id,
|
||||
form_id,
|
||||
)
|
||||
|
||||
if email_delivery_enabled:
|
||||
return contexts_tuple
|
||||
|
||||
return tuple(context for context in contexts_tuple if context.delivery_method_type != DeliveryMethodType.EMAIL)
|
||||
|
||||
|
||||
def _is_email_delivery_enabled(*, tenant_id: str, form_id: str) -> bool:
|
||||
features = FeatureService.get_features(tenant_id, exclude_vector_space=True)
|
||||
email_delivery_enabled = features.human_input_email_delivery_enabled
|
||||
if not email_delivery_enabled:
|
||||
logger.info(
|
||||
"Human input email delivery is not available for tenant=%s, form_id=%s",
|
||||
tenant_id,
|
||||
form_id,
|
||||
)
|
||||
return email_delivery_enabled
|
||||
|
||||
|
||||
def _load_and_dispatch_form_delivery(
|
||||
*,
|
||||
form_id: str,
|
||||
node_title: str | None,
|
||||
session_factory,
|
||||
delivery_method_types: Sequence[DeliveryMethodType] | None,
|
||||
require_mail_inited: bool,
|
||||
require_email_feature: bool,
|
||||
filter_unavailable_email: bool,
|
||||
log_label: str,
|
||||
) -> None:
|
||||
if require_mail_inited and not mail.is_inited():
|
||||
return
|
||||
|
||||
logger.info(click.style(f"Start human input email delivery for form {form_id}", fg="green"))
|
||||
logger.info(click.style(f"Start human input {log_label} delivery for form {form_id}", fg="green"))
|
||||
start_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
@@ -157,35 +131,67 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
|
||||
if form is None:
|
||||
logger.warning("Human input form not found, form_id=%s", form_id)
|
||||
return
|
||||
features = FeatureService.get_features(form.tenant_id, exclude_vector_space=True)
|
||||
if not features.human_input_email_delivery_enabled:
|
||||
logger.info(
|
||||
"Human input email delivery is not available for tenant=%s, form_id=%s",
|
||||
form.tenant_id,
|
||||
form_id,
|
||||
)
|
||||
|
||||
if require_email_feature and not _is_email_delivery_enabled(tenant_id=form.tenant_id, form_id=form_id):
|
||||
return
|
||||
jobs = _load_email_jobs(session, form)
|
||||
|
||||
variable_pool = _load_variable_pool(form.workflow_run_id)
|
||||
variable_pool = _load_variable_pool(form.workflow_run_id)
|
||||
registry = HumanInputFormDeliveryProviderRegistry.default(mail_client=mail)
|
||||
dispatcher = HumanInputFormDeliveryDispatcher(registry=registry)
|
||||
contexts = dispatcher.load_form_contexts(
|
||||
session=session,
|
||||
form=form,
|
||||
variable_pool=variable_pool,
|
||||
delivery_method_types=delivery_method_types,
|
||||
)
|
||||
|
||||
for job in jobs:
|
||||
for recipient in job.recipients:
|
||||
form_link = _build_form_link(recipient.token)
|
||||
body = _render_body(job.body, form_link, variable_pool=variable_pool)
|
||||
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
|
||||
|
||||
mail.send(
|
||||
to=recipient.email,
|
||||
subject=subject,
|
||||
html=body,
|
||||
)
|
||||
if filter_unavailable_email:
|
||||
contexts = _filter_unavailable_email_contexts(
|
||||
contexts=contexts,
|
||||
tenant_id=form.tenant_id,
|
||||
form_id=form_id,
|
||||
)
|
||||
dispatcher.dispatch_contexts(contexts)
|
||||
|
||||
end_at = time.perf_counter()
|
||||
logger.info(
|
||||
click.style(
|
||||
f"Human input email delivery succeeded for form {form_id}: latency: {end_at - start_at}", fg="green"
|
||||
f"Human input {log_label} delivery succeeded for form {form_id}: latency: {end_at - start_at}",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Send human input email failed, form_id=%s", form_id)
|
||||
logger.exception(
|
||||
"Dispatch human input %s delivery failed, form_id=%s, node_title=%s",
|
||||
log_label,
|
||||
form_id,
|
||||
node_title,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def dispatch_human_input_form_delivery_task(form_id: str, node_title: str | None = None, session_factory=None):
|
||||
_load_and_dispatch_form_delivery(
|
||||
form_id=form_id,
|
||||
node_title=node_title,
|
||||
session_factory=session_factory,
|
||||
delivery_method_types=FORM_DELIVERY_METHOD_TYPES,
|
||||
require_mail_inited=False,
|
||||
require_email_feature=False,
|
||||
filter_unavailable_email=True,
|
||||
log_label="form",
|
||||
)
|
||||
|
||||
|
||||
@shared_task(queue="mail")
|
||||
def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None):
|
||||
_load_and_dispatch_form_delivery(
|
||||
form_id=form_id,
|
||||
node_title=node_title,
|
||||
session_factory=session_factory,
|
||||
delivery_method_types=(DeliveryMethodType.EMAIL,),
|
||||
require_mail_inited=True,
|
||||
require_email_feature=True,
|
||||
filter_unavailable_email=False,
|
||||
log_label="email",
|
||||
)
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
"""PostgreSQL-only transaction and concurrency coverage for Contact Directory."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier, Event, get_ident
|
||||
from time import monotonic, sleep
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactDirectorySnapshot,
|
||||
ContactRejectionCode,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, ContactId, UtcTimestamp, WorkspaceId
|
||||
from extensions.ext_database import db
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.account import Account, AccountStatus, TenantAccountJoin
|
||||
from models.human_input_v2 import HumanInputContact, HumanInputPlatformContactWorkspaceEntry
|
||||
from repositories.human_input_v2.contact_directory.repository import SQLAlchemyContactDirectoryRepository
|
||||
|
||||
|
||||
def test_concurrent_organization_writes_serialize_on_dify_setup(flask_req_ctx, setup_account) -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(HumanInputContact).where(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.account_id == setup_account.id,
|
||||
)
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def write_contact(contact_id: str) -> Contact | ContactRejectionCode:
|
||||
repository = SQLAlchemyContactDirectoryRepository(session_maker)
|
||||
contact = Contact.organization_account(
|
||||
contact_id=ContactId(contact_id),
|
||||
account_id=AccountId(setup_account.id),
|
||||
name="Concurrent Account",
|
||||
email="concurrent@example.com",
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
barrier.wait()
|
||||
try:
|
||||
return repository.save_organization_contact(contact)
|
||||
except ContactDirectoryError as error:
|
||||
return error.code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(write_contact, (str(uuidv7()), str(uuidv7()))))
|
||||
|
||||
assert sum(isinstance(result, Contact) for result in results) == 1
|
||||
assert results.count(ContactRejectionCode.CONFLICTING_IDENTITY) == 1
|
||||
with session_maker() as session:
|
||||
count = session.scalar(
|
||||
sa.select(sa.func.count(HumanInputContact.id)).where(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.account_id == setup_account.id,
|
||||
)
|
||||
)
|
||||
assert count == 1
|
||||
finally:
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(HumanInputContact).where(
|
||||
HumanInputContact.tenant_id.is_(None),
|
||||
HumanInputContact.account_id == setup_account.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_concurrent_organization_and_external_admission_share_identity_claim(flask_req_ctx, setup_account) -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
workspace_id = session.scalar(
|
||||
sa.select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == setup_account.id)
|
||||
)
|
||||
assert workspace_id is not None
|
||||
normalized_email = f"concurrent-org-external-{uuidv7()}@example.com"
|
||||
organization_account_id = str(uuidv7())
|
||||
organization_contact_id = ContactId(str(uuidv7()))
|
||||
with session_maker.begin() as session:
|
||||
organization_account = Account(
|
||||
name="Concurrent Organization Account",
|
||||
email=normalized_email,
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
organization_account.id = organization_account_id
|
||||
session.add(organization_account)
|
||||
first_lock_acquired = Event()
|
||||
external_connection_ready = Event()
|
||||
release_first_lock = Event()
|
||||
organization_thread_id: list[int] = []
|
||||
organization_backend_pid: list[int] = []
|
||||
external_backend_pid: list[int] = []
|
||||
|
||||
def is_deployment_lock_statement(statement: str) -> bool:
|
||||
normalized_statement = " ".join(statement.lower().split())
|
||||
return (
|
||||
normalized_statement.startswith("select")
|
||||
and "from dify_setups" in normalized_statement
|
||||
and "for update" in normalized_statement
|
||||
)
|
||||
|
||||
def pause_after_first_lock(
|
||||
_connection,
|
||||
_cursor,
|
||||
statement,
|
||||
_parameters,
|
||||
_context,
|
||||
_executemany,
|
||||
) -> None:
|
||||
if (
|
||||
organization_thread_id
|
||||
and get_ident() == organization_thread_id[0]
|
||||
and is_deployment_lock_statement(statement)
|
||||
):
|
||||
first_lock_acquired.set()
|
||||
assert release_first_lock.wait(timeout=10)
|
||||
|
||||
def save_organization_contact() -> Contact | ContactRejectionCode:
|
||||
with db.engine.connect() as connection:
|
||||
backend_pid = connection.scalar(sa.select(sa.func.pg_backend_pid()))
|
||||
assert backend_pid is not None
|
||||
organization_backend_pid.append(backend_pid)
|
||||
connection.commit()
|
||||
repository = SQLAlchemyContactDirectoryRepository(sessionmaker(bind=connection, expire_on_commit=False))
|
||||
organization_thread_id.append(get_ident())
|
||||
try:
|
||||
return repository.save_organization_contact(
|
||||
Contact.organization_account(
|
||||
contact_id=organization_contact_id,
|
||||
account_id=AccountId(organization_account_id),
|
||||
name="Concurrent Organization Account",
|
||||
email=normalized_email,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
)
|
||||
except ContactDirectoryError as error:
|
||||
return error.code
|
||||
|
||||
def admit_external() -> Contact | ContactRejectionCode:
|
||||
with db.engine.connect() as connection:
|
||||
backend_pid = connection.scalar(sa.select(sa.func.pg_backend_pid()))
|
||||
assert backend_pid is not None
|
||||
external_backend_pid.append(backend_pid)
|
||||
connection.commit()
|
||||
external_connection_ready.set()
|
||||
repository = SQLAlchemyContactDirectoryRepository(sessionmaker(bind=connection, expire_on_commit=False))
|
||||
try:
|
||||
return repository.admit_external(
|
||||
WorkspaceId(workspace_id),
|
||||
name="Concurrent External",
|
||||
email=normalized_email,
|
||||
)
|
||||
except ContactDirectoryError as error:
|
||||
return error.code
|
||||
|
||||
def wait_until_external_is_blocked() -> None:
|
||||
deadline = monotonic() + 10
|
||||
with session_maker() as observer:
|
||||
while monotonic() < deadline:
|
||||
blocking_pids = observer.scalar(sa.select(sa.func.pg_blocking_pids(external_backend_pid[0])))
|
||||
if blocking_pids is not None and organization_backend_pid[0] in blocking_pids:
|
||||
return
|
||||
sleep(0.01)
|
||||
raise AssertionError("External admission did not block on the Organization deployment lock")
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=2)
|
||||
event.listen(db.engine, "after_cursor_execute", pause_after_first_lock)
|
||||
try:
|
||||
organization_future = executor.submit(save_organization_contact)
|
||||
assert first_lock_acquired.wait(timeout=10)
|
||||
external_future = executor.submit(admit_external)
|
||||
assert external_connection_ready.wait(timeout=10)
|
||||
wait_until_external_is_blocked()
|
||||
assert not external_future.done()
|
||||
|
||||
release_first_lock.set()
|
||||
results = [organization_future.result(timeout=10), external_future.result(timeout=10)]
|
||||
|
||||
assert sum(isinstance(result, Contact) for result in results) == 1
|
||||
assert results.count(ContactRejectionCode.CONFLICTING_IDENTITY) == 1
|
||||
assert isinstance(results[0], Contact)
|
||||
assert results[1] is ContactRejectionCode.CONFLICTING_IDENTITY
|
||||
with session_maker() as session:
|
||||
contact_count = session.scalar(
|
||||
sa.select(sa.func.count(HumanInputContact.id)).where(
|
||||
HumanInputContact.normalized_email == normalized_email
|
||||
)
|
||||
)
|
||||
assert contact_count == 1
|
||||
finally:
|
||||
release_first_lock.set()
|
||||
event.remove(db.engine, "after_cursor_execute", pause_after_first_lock)
|
||||
executor.shutdown(wait=True, cancel_futures=True)
|
||||
with session_maker.begin() as session:
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.normalized_email == normalized_email))
|
||||
session.execute(sa.delete(Account).where(Account.id == organization_account_id))
|
||||
|
||||
|
||||
def test_concurrent_platform_enable_is_idempotent(flask_req_ctx, setup_account) -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
workspace_id = session.scalar(
|
||||
sa.select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == setup_account.id)
|
||||
)
|
||||
assert workspace_id is not None
|
||||
repository = SQLAlchemyContactDirectoryRepository(session_maker)
|
||||
contact = repository.save_organization_contact(
|
||||
Contact.organization_account(
|
||||
contact_id=ContactId(str(uuidv7())),
|
||||
account_id=AccountId(setup_account.id),
|
||||
name="Concurrent Platform Account",
|
||||
email="concurrent-platform@example.com",
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def enable_platform_contact() -> ContactRejectionCode | None:
|
||||
concurrent_repository = SQLAlchemyContactDirectoryRepository(session_maker)
|
||||
barrier.wait()
|
||||
try:
|
||||
concurrent_repository.set_platform_availability(
|
||||
WorkspaceId(workspace_id),
|
||||
contact.id,
|
||||
added_by_account_id=AccountId(setup_account.id),
|
||||
enabled=True,
|
||||
)
|
||||
except ContactDirectoryError as error:
|
||||
return error.code
|
||||
return None
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(lambda _index: enable_platform_contact(), range(2)))
|
||||
|
||||
assert results == [None, None]
|
||||
with session_maker() as session:
|
||||
entry_count = session.scalar(
|
||||
sa.select(sa.func.count(HumanInputPlatformContactWorkspaceEntry.id)).where(
|
||||
HumanInputPlatformContactWorkspaceEntry.tenant_id == workspace_id,
|
||||
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id),
|
||||
)
|
||||
)
|
||||
assert entry_count == 1
|
||||
finally:
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
|
||||
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id)
|
||||
)
|
||||
)
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(contact.id)))
|
||||
|
||||
|
||||
def test_snapshot_uses_one_repeatable_read_view_across_statements(flask_req_ctx, setup_account) -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_maker() as session:
|
||||
membership = session.scalar(
|
||||
sa.select(TenantAccountJoin).where(TenantAccountJoin.account_id == setup_account.id)
|
||||
)
|
||||
assert membership is not None
|
||||
workspace_id = WorkspaceId(membership.tenant_id)
|
||||
repository = SQLAlchemyContactDirectoryRepository(session_maker)
|
||||
contact = repository.save_organization_contact(
|
||||
Contact.organization_account(
|
||||
contact_id=ContactId(str(uuidv7())),
|
||||
account_id=AccountId(setup_account.id),
|
||||
name="Snapshot Account",
|
||||
email="snapshot-account@example.com",
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
)
|
||||
contact_query_finished = Event()
|
||||
mutation_committed = Event()
|
||||
loader_thread_id: list[int] = []
|
||||
|
||||
def pause_after_contact_query(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
normalized_statement = " ".join(statement.lower().split())
|
||||
if (
|
||||
loader_thread_id
|
||||
and get_ident() == loader_thread_id[0]
|
||||
and normalized_statement.startswith("select")
|
||||
and "from human_input_contacts" in normalized_statement
|
||||
):
|
||||
contact_query_finished.set()
|
||||
assert mutation_committed.wait(timeout=10)
|
||||
|
||||
def load_snapshot() -> ContactDirectorySnapshot:
|
||||
loader_thread_id.append(get_ident())
|
||||
return repository.load_snapshot(workspace_id)
|
||||
|
||||
event.listen(db.engine, "after_cursor_execute", pause_after_contact_query)
|
||||
future = None
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(load_snapshot)
|
||||
assert contact_query_finished.wait(timeout=10)
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(TenantAccountJoin).where(
|
||||
TenantAccountJoin.tenant_id == str(workspace_id),
|
||||
TenantAccountJoin.account_id == setup_account.id,
|
||||
)
|
||||
)
|
||||
account = session.get_one(Account, setup_account.id)
|
||||
account.status = AccountStatus.BANNED
|
||||
entry = HumanInputPlatformContactWorkspaceEntry(
|
||||
tenant_id=str(workspace_id),
|
||||
contact_id=str(contact.id),
|
||||
added_by_account_id=setup_account.id,
|
||||
)
|
||||
entry.id = str(uuidv7())
|
||||
session.add(entry)
|
||||
mutation_committed.set()
|
||||
snapshot = future.result(timeout=10)
|
||||
|
||||
assert snapshot.member_account_ids == frozenset({AccountId(setup_account.id)})
|
||||
assert snapshot.platform_contact_ids == frozenset()
|
||||
assert snapshot.unavailable_account_ids == frozenset()
|
||||
finally:
|
||||
mutation_committed.set()
|
||||
event.remove(db.engine, "after_cursor_execute", pause_after_contact_query)
|
||||
if future is not None and not future.done():
|
||||
future.result(timeout=10)
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
|
||||
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id)
|
||||
)
|
||||
)
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(contact.id)))
|
||||
account = session.get_one(Account, setup_account.id)
|
||||
account.status = AccountStatus.ACTIVE
|
||||
existing_membership = session.scalar(
|
||||
sa.select(TenantAccountJoin.id).where(
|
||||
TenantAccountJoin.tenant_id == str(workspace_id),
|
||||
TenantAccountJoin.account_id == setup_account.id,
|
||||
)
|
||||
)
|
||||
if existing_membership is None:
|
||||
restored_membership = TenantAccountJoin(
|
||||
tenant_id=str(workspace_id),
|
||||
account_id=setup_account.id,
|
||||
role=membership.role,
|
||||
current=membership.current,
|
||||
invited_by=membership.invited_by,
|
||||
)
|
||||
restored_membership.id = membership.id
|
||||
session.add(restored_membership)
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""PostgreSQL-only concurrency coverage for the IM Control Plane."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.human_input_v2.contact_directory import Contact
|
||||
from core.human_input_v2.entities import IMProvider
|
||||
from core.human_input_v2.im_integration import (
|
||||
ActiveRunDecisionKind,
|
||||
ApplyReconciliationStatus,
|
||||
ConfigurationTransition,
|
||||
EncryptedCredentials,
|
||||
IMIntegration,
|
||||
MatchKind,
|
||||
ProviderDirectoryEntry,
|
||||
ProviderTenantIdentity,
|
||||
ReconciliationAction,
|
||||
ReconciliationPlan,
|
||||
StaleRevision,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, ContactId, IMSyncRunId, IntegrationId, UtcTimestamp, WorkspaceId
|
||||
from extensions.ext_database import db
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputIMSyncResult,
|
||||
HumanInputIMSyncRun,
|
||||
)
|
||||
from repositories.human_input_v2.contact_directory.mappers import contact_to_record
|
||||
from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
|
||||
def _integration(integration_id: str, workspace_id: str | None) -> IMIntegration:
|
||||
return IMIntegration.create(
|
||||
integration_id=IntegrationId(integration_id),
|
||||
workspace_id=WorkspaceId(workspace_id) if workspace_id is not None else None,
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, f"provider-{workspace_id or 'deployment'}"),
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping(
|
||||
{"app_id": "app-1", "encrypted_app_secret": "ciphertext"}
|
||||
),
|
||||
configured_by_account_id=AccountId(str(uuidv7())),
|
||||
callback_url=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
|
||||
|
||||
def _cleanup(
|
||||
session_maker,
|
||||
integration_ids: tuple[str, ...],
|
||||
*,
|
||||
contact_ids: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
with session_maker.begin() as session:
|
||||
run_ids = session.scalars(
|
||||
sa.select(HumanInputIMSyncRun.id).where(HumanInputIMSyncRun.integration_id.in_(integration_ids))
|
||||
).all()
|
||||
if run_ids:
|
||||
session.execute(sa.delete(HumanInputIMSyncResult).where(HumanInputIMSyncResult.sync_run_id.in_(run_ids)))
|
||||
session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id.in_(integration_ids)))
|
||||
session.execute(sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id.in_(integration_ids)))
|
||||
session.execute(sa.delete(HumanInputIMSyncRun).where(HumanInputIMSyncRun.integration_id.in_(integration_ids)))
|
||||
session.execute(sa.delete(HumanInputIMIntegration).where(HumanInputIMIntegration.id.in_(integration_ids)))
|
||||
if contact_ids:
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id.in_(contact_ids)))
|
||||
|
||||
|
||||
def test_concurrent_deployment_integration_creation_has_exactly_one_winner(flask_req_ctx, setup_account) -> None:
|
||||
_require_postgresql()
|
||||
assert setup_account.id
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
integration_ids = (str(uuidv7()), str(uuidv7()))
|
||||
with session_maker() as session:
|
||||
if session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMIntegration.id)).where(HumanInputIMIntegration.tenant_id.is_(None))
|
||||
):
|
||||
pytest.skip("requires a deployment without an existing IM integration")
|
||||
barrier = Barrier(2)
|
||||
|
||||
def create(integration_id: str) -> IMIntegration | str:
|
||||
barrier.wait()
|
||||
try:
|
||||
return SQLAlchemyIMControlPlaneRepository(session_maker).create_integration(
|
||||
_integration(integration_id, None)
|
||||
)
|
||||
except ValueError as error:
|
||||
return str(error)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(create, integration_ids))
|
||||
|
||||
assert sum(isinstance(result, IMIntegration) for result in results) == 1
|
||||
assert results.count("deployment-wide IM integration already exists") == 1
|
||||
with session_maker() as session:
|
||||
count = session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMIntegration.id)).where(
|
||||
HumanInputIMIntegration.id.in_(integration_ids),
|
||||
HumanInputIMIntegration.tenant_id.is_(None),
|
||||
)
|
||||
)
|
||||
assert count == 1
|
||||
finally:
|
||||
_cleanup(session_maker, integration_ids)
|
||||
|
||||
|
||||
def test_concurrent_configuration_cas_has_exactly_one_winner(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
integration_id = str(uuidv7())
|
||||
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
|
||||
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
|
||||
barrier = Barrier(2)
|
||||
|
||||
def rotate(secret: str) -> IMIntegration | StaleRevision:
|
||||
decision = integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping(
|
||||
{"app_id": "app-1", "encrypted_app_secret": secret}
|
||||
),
|
||||
configured_by_account_id=None,
|
||||
callback_url=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
assert isinstance(decision, ConfigurationTransition)
|
||||
barrier.wait()
|
||||
return SQLAlchemyIMControlPlaneRepository(session_maker).compare_and_swap_configuration(decision)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(rotate, ("ciphertext-1", "ciphertext-2")))
|
||||
|
||||
assert sum(isinstance(result, IMIntegration) for result in results) == 1
|
||||
assert sum(isinstance(result, StaleRevision) for result in results) == 1
|
||||
finally:
|
||||
_cleanup(session_maker, (integration_id,))
|
||||
|
||||
|
||||
def test_concurrent_sync_triggers_create_at_most_one_active_run(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
integration_id = str(uuidv7())
|
||||
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
|
||||
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
|
||||
barrier = Barrier(2)
|
||||
|
||||
def trigger(_index: int):
|
||||
barrier.wait()
|
||||
return SQLAlchemyIMControlPlaneRepository(session_maker).create_or_get_active_run(
|
||||
integration.revision,
|
||||
sync_run_id=IMSyncRunId(str(uuidv7())),
|
||||
started_by_account_id=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(trigger, range(2)))
|
||||
|
||||
assert [result.kind for result in results].count(ActiveRunDecisionKind.CREATED) == 1
|
||||
assert [result.kind for result in results].count(ActiveRunDecisionKind.EXISTING_ACTIVE) == 1
|
||||
with session_maker() as session:
|
||||
count = session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMSyncRun.id)).where(
|
||||
HumanInputIMSyncRun.integration_id == integration_id
|
||||
)
|
||||
)
|
||||
assert count == 1
|
||||
finally:
|
||||
_cleanup(session_maker, (integration_id,))
|
||||
|
||||
|
||||
def test_concurrent_worker_retry_applies_one_sync_run_idempotently(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
integration_id = str(uuidv7())
|
||||
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
|
||||
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
|
||||
run_decision = repository.create_or_get_active_run(
|
||||
integration.revision,
|
||||
sync_run_id=IMSyncRunId(str(uuidv7())),
|
||||
started_by_account_id=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
assert run_decision.run is not None
|
||||
contact_id = ContactId(str(uuidv7()))
|
||||
contact = Contact.organization_account(
|
||||
contact_id=contact_id,
|
||||
account_id=AccountId(str(uuidv7())),
|
||||
name="Concurrent Retry Reviewer",
|
||||
email=f"concurrent-retry-{uuidv7()}@example.com",
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
with session_maker.begin() as session:
|
||||
session.add(contact_to_record(contact))
|
||||
entry = ProviderDirectoryEntry.create(
|
||||
provider_user_id=f"provider-user-{uuidv7()}",
|
||||
display_name=contact.name,
|
||||
email=contact.email,
|
||||
raw_payload={"source": "concurrent-retry"},
|
||||
)
|
||||
plan = ReconciliationPlan(
|
||||
sync_run_id=run_decision.run.id,
|
||||
integration_revision=integration.revision,
|
||||
provider=integration.provider_tenant.provider,
|
||||
actions=(
|
||||
ReconciliationAction(
|
||||
entry=entry,
|
||||
match_kind=MatchKind.NORMALIZED_EMAIL,
|
||||
identity_id=None,
|
||||
binding_id=None,
|
||||
contact_id=contact.id,
|
||||
),
|
||||
),
|
||||
removed_identity_ids=(),
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def apply(_index: int):
|
||||
barrier.wait()
|
||||
return SQLAlchemyIMControlPlaneRepository(session_maker).apply_reconciliation(plan, now=UtcTimestamp.now())
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(apply, range(2)))
|
||||
|
||||
assert [result.status for result in results].count(ApplyReconciliationStatus.APPLIED) == 1
|
||||
assert [result.status for result in results].count(ApplyReconciliationStatus.ALREADY_APPLIED) == 1
|
||||
assert all(len(result.results) == 1 for result in results)
|
||||
assert results[0].results[0].id == results[1].results[0].id
|
||||
with session_maker() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMIdentity.id)).where(
|
||||
HumanInputIMIdentity.integration_id == integration_id
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMBinding.id)).where(
|
||||
HumanInputIMBinding.integration_id == integration_id
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMSyncResult.id)).where(
|
||||
HumanInputIMSyncResult.sync_run_id == str(plan.sync_run_id)
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
finally:
|
||||
_cleanup(session_maker, (integration_id,), contact_ids=(str(contact_id),))
|
||||
|
||||
|
||||
def test_stale_reconciliation_records_diagnostic_without_current_mutation(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
integration_id = str(uuidv7())
|
||||
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
|
||||
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
|
||||
run_decision = repository.create_or_get_active_run(
|
||||
integration.revision,
|
||||
sync_run_id=IMSyncRunId(str(uuidv7())),
|
||||
started_by_account_id=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
assert run_decision.run is not None
|
||||
rotation = integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping({"app_id": "app-1", "encrypted_app_secret": "rotated"}),
|
||||
configured_by_account_id=None,
|
||||
callback_url=None,
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
assert isinstance(rotation, ConfigurationTransition)
|
||||
repository.compare_and_swap_configuration(rotation)
|
||||
plan = ReconciliationPlan(
|
||||
sync_run_id=run_decision.run.id,
|
||||
integration_revision=integration.revision,
|
||||
provider=integration.provider_tenant.provider,
|
||||
actions=(),
|
||||
removed_identity_ids=(),
|
||||
)
|
||||
|
||||
try:
|
||||
result = repository.apply_reconciliation(plan, now=UtcTimestamp.now())
|
||||
|
||||
assert result.status is ApplyReconciliationStatus.STALE_REVISION
|
||||
with session_maker() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMSyncResult.id)).where(
|
||||
HumanInputIMSyncResult.sync_run_id == str(plan.sync_run_id)
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputIMIdentity.id)).where(
|
||||
HumanInputIMIdentity.integration_id == integration_id
|
||||
)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
_cleanup(session_maker, (integration_id,))
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
"""PostgreSQL-only concurrency coverage for grant-scoped OTP replacement."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import timedelta
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.approval import FormRef, OTPChallengeRejectionReason, OTPCodeHash
|
||||
from core.human_input_v2.entities import (
|
||||
HumanInputApproverGrantSubjectType,
|
||||
HumanInputOTPChallengeStatus,
|
||||
HumanInputV2FormKind,
|
||||
HumanInputV2FormStatus,
|
||||
)
|
||||
from core.human_input_v2.shared import ApproverGrantId, FormId, OTPChallengeId, UtcTimestamp, WorkspaceId
|
||||
from extensions.ext_database import db
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.human_input_v2 import (
|
||||
FormApproverGrantMatchedSources,
|
||||
FormApproverGrantSubjectSnapshot,
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormDefinition,
|
||||
HumanInputV2FormOTPChallenge,
|
||||
)
|
||||
from repositories.human_input_v2.approval.repository import (
|
||||
OTPChallengeAuditFact,
|
||||
SQLAlchemyOTPChallengeRepository,
|
||||
)
|
||||
|
||||
|
||||
class _FixedClock:
|
||||
def __init__(self, now: UtcTimestamp) -> None:
|
||||
self._now = now
|
||||
|
||||
def now(self) -> UtcTimestamp:
|
||||
return self._now
|
||||
|
||||
|
||||
class _StatelessHasher:
|
||||
def hash_code(self, plaintext_code: str) -> OTPCodeHash:
|
||||
return OTPCodeHash(f"integration:{plaintext_code}", "test")
|
||||
|
||||
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool:
|
||||
return code_hash.encoded_value == f"integration:{plaintext_code}"
|
||||
|
||||
|
||||
class _TransactionOnlyAuditWriter:
|
||||
"""Concurrency test seam; unit tests cover the concrete audit-row writer."""
|
||||
|
||||
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
|
||||
session.execute(sa.select(sa.literal(fact.audit_event_id)))
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
|
||||
def test_concurrent_resend_leaves_exactly_one_usable_challenge(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
workspace_id = str(uuidv7())
|
||||
form_id = str(uuidv7())
|
||||
grant_id = str(uuidv7())
|
||||
contact_id = str(uuidv7())
|
||||
initial_challenge_id = str(uuidv7())
|
||||
replacement_ids = (str(uuidv7()), str(uuidv7()))
|
||||
issued_at = UtcTimestamp.now()
|
||||
grant_ref = FormRef(WorkspaceId(workspace_id), FormId(form_id)).grant(ApproverGrantId(grant_id))
|
||||
contact_email = f"otp-{uuidv7()}@example.com"
|
||||
contact = HumanInputContact(
|
||||
name="Concurrent OTP Reviewer",
|
||||
normalized_name="concurrent otp reviewer",
|
||||
identity_source=HumanInputContactIdentitySource.EXTERNAL,
|
||||
tenant_id=workspace_id,
|
||||
email=contact_email,
|
||||
normalized_email=contact_email,
|
||||
)
|
||||
contact.id = contact_id
|
||||
form = HumanInputV2Form(
|
||||
tenant_id=workspace_id,
|
||||
app_id=str(uuidv7()),
|
||||
form_definition=HumanInputV2FormDefinition(form_content="Approve"),
|
||||
rendered_content="Approve",
|
||||
node_timeout_at=issued_at.value + timedelta(hours=1),
|
||||
global_expires_at=issued_at.value + timedelta(hours=2),
|
||||
form_kind=HumanInputV2FormKind.DELIVERY_TEST,
|
||||
status=HumanInputV2FormStatus.WAITING,
|
||||
workflow_pause_id=None,
|
||||
node_execution_id=None,
|
||||
)
|
||||
form.id = form_id
|
||||
grant = HumanInputV2FormApproverGrant(
|
||||
tenant_id=workspace_id,
|
||||
form_id=form_id,
|
||||
subject_type=HumanInputApproverGrantSubjectType.CONTACT,
|
||||
subject_key=f"contact:{contact_id}",
|
||||
matched_sources=FormApproverGrantMatchedSources(),
|
||||
subject_snapshot=FormApproverGrantSubjectSnapshot(email=contact.email),
|
||||
contact_id=contact_id,
|
||||
)
|
||||
grant.id = grant_id
|
||||
with session_maker.begin() as session:
|
||||
session.add_all([contact, form, grant])
|
||||
|
||||
audit_writer = _TransactionOnlyAuditWriter()
|
||||
SQLAlchemyOTPChallengeRepository(
|
||||
session_maker,
|
||||
clock=_FixedClock(issued_at),
|
||||
code_hasher=_StatelessHasher(),
|
||||
audit_writer=audit_writer,
|
||||
).issue_initial(
|
||||
grant_ref,
|
||||
challenge_id=OTPChallengeId(initial_challenge_id),
|
||||
audit_event_id=str(uuidv7()),
|
||||
challenge_token_hash="a" * 64,
|
||||
plaintext_code="123456",
|
||||
)
|
||||
resend_clock = _FixedClock(UtcTimestamp(issued_at.value + timedelta(seconds=60)))
|
||||
barrier = Barrier(2)
|
||||
|
||||
def resend(index: int):
|
||||
barrier.wait()
|
||||
return SQLAlchemyOTPChallengeRepository(
|
||||
session_maker,
|
||||
clock=resend_clock,
|
||||
code_hasher=_StatelessHasher(),
|
||||
audit_writer=audit_writer,
|
||||
).replace_current(
|
||||
grant_ref,
|
||||
challenge_id=OTPChallengeId(replacement_ids[index]),
|
||||
audit_event_id=str(uuidv7()),
|
||||
challenge_token_hash=("b" if index == 0 else "c") * 64,
|
||||
plaintext_code="123456",
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(executor.map(resend, range(2)))
|
||||
|
||||
assert sum(result.replacement is not None for result in results) == 1
|
||||
assert [result.rejection for result in results].count(OTPChallengeRejectionReason.RESEND_COOLDOWN) == 1
|
||||
with session_maker() as session:
|
||||
records = session.scalars(
|
||||
sa.select(HumanInputV2FormOTPChallenge).where(
|
||||
HumanInputV2FormOTPChallenge.tenant_id == workspace_id,
|
||||
HumanInputV2FormOTPChallenge.form_id == form_id,
|
||||
HumanInputV2FormOTPChallenge.approver_grant_id == grant_id,
|
||||
)
|
||||
).all()
|
||||
assert len(records) == 2
|
||||
assert sum(record.status is HumanInputOTPChallengeStatus.PENDING for record in records) == 1
|
||||
finally:
|
||||
with session_maker.begin() as session:
|
||||
session.execute(
|
||||
sa.delete(HumanInputV2FormOTPChallenge).where(
|
||||
HumanInputV2FormOTPChallenge.approver_grant_id == grant_id
|
||||
)
|
||||
)
|
||||
session.execute(
|
||||
sa.delete(HumanInputV2FormApproverGrant).where(HumanInputV2FormApproverGrant.id == grant_id)
|
||||
)
|
||||
session.execute(sa.delete(HumanInputV2Form).where(HumanInputV2Form.id == form_id))
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == contact_id))
|
||||
+682
@@ -0,0 +1,682 @@
|
||||
"""PostgreSQL-only first-success and authorization-snapshot coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from threading import Event, Lock, get_ident
|
||||
from time import monotonic, sleep
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ApproverGrant,
|
||||
AuthorizationContext,
|
||||
AuthorizedSubmissionCommit,
|
||||
CanonicalSubjectKey,
|
||||
ContactApprovalSubject,
|
||||
ContactOTPSubject,
|
||||
DeliveryEndpoint,
|
||||
EmailEndpointConfiguration,
|
||||
FormAuthorizationAuditEvent,
|
||||
FormRef,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
IMEndpointConfiguration,
|
||||
SubjectSnapshot,
|
||||
SubmissionAttemptScope,
|
||||
SubmissionAuthorizer,
|
||||
SubmissionCommitResult,
|
||||
SubmissionCommitStatus,
|
||||
SubmissionRepository,
|
||||
SubmissionTransaction,
|
||||
VerifiedEmailOTPProof,
|
||||
VerifiedIMIdentityProof,
|
||||
)
|
||||
from core.human_input_v2.entities import (
|
||||
HumanInputV2FormKind,
|
||||
HumanInputV2FormStatus,
|
||||
IMBindingScope,
|
||||
IMIntegrationStatus,
|
||||
IMProvider,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
AuditEventId,
|
||||
ContactId,
|
||||
DeliveryEndpointId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
SubmissionId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from libs.uuid_utils import uuidv7
|
||||
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.human_input_v2 import (
|
||||
HumanInputContact,
|
||||
HumanInputContactIdentitySource,
|
||||
HumanInputIMBinding,
|
||||
HumanInputIMIdentity,
|
||||
HumanInputIMIntegration,
|
||||
HumanInputV2Form,
|
||||
HumanInputV2FormApproverGrant,
|
||||
HumanInputV2FormAuditEvent,
|
||||
HumanInputV2FormDeliveryEndpoint,
|
||||
HumanInputV2FormSubmission,
|
||||
IMIdentityRawPayload,
|
||||
SlackIMIntegrationEncryptedCredentials,
|
||||
)
|
||||
from repositories.human_input_v2.form.mappers import endpoint_to_record, form_to_record, grant_to_record
|
||||
from repositories.human_input_v2.submission.repository import SQLAlchemySubmissionRepository
|
||||
from services.human_input_v2.submission import (
|
||||
SubmitFormCommand,
|
||||
SubmitFormResult,
|
||||
SubmitFormResultStatus,
|
||||
SubmitHumanInputFormHandler,
|
||||
WorkflowResumeIdentity,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SeededScenario:
|
||||
workspace_id: WorkspaceId
|
||||
account_id: AccountId
|
||||
contact_id: ContactId
|
||||
form_ref: FormRef
|
||||
grant_id: ApproverGrantId
|
||||
email_endpoint_id: DeliveryEndpointId
|
||||
im_endpoint_id: DeliveryEndpointId
|
||||
integration_id: IntegrationId
|
||||
identity_id: IMIdentityId
|
||||
binding_id: IMBindingId
|
||||
normalized_email: NormalizedEmail
|
||||
provider_tenant_id: str
|
||||
provider_user_id: str
|
||||
workflow_pause_id: str
|
||||
node_execution_id: str
|
||||
|
||||
|
||||
class _RecordingResumePort:
|
||||
def __init__(self) -> None:
|
||||
self._lock = Lock()
|
||||
self.identities: set[WorkflowResumeIdentity] = set()
|
||||
|
||||
def enqueue_once(self, identity: WorkflowResumeIdentity) -> None:
|
||||
with self._lock:
|
||||
self.identities.add(identity)
|
||||
|
||||
|
||||
class _PausingSubmissionTransaction:
|
||||
def __init__(
|
||||
self,
|
||||
delegate: SubmissionTransaction,
|
||||
*,
|
||||
context_loaded: Event,
|
||||
release_transaction: Event,
|
||||
) -> None:
|
||||
self._delegate = delegate
|
||||
self._context_loaded = context_loaded
|
||||
self._release_transaction = release_transaction
|
||||
|
||||
def load_authorization_context(self, *, proof: object) -> AuthorizationContext:
|
||||
context = self._delegate.load_authorization_context(proof=proof)
|
||||
self._context_loaded.set()
|
||||
if not self._release_transaction.wait(timeout=10):
|
||||
raise AssertionError("winner transaction was not released")
|
||||
return context
|
||||
|
||||
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None:
|
||||
self._delegate.append_rejection_audit(event)
|
||||
|
||||
def commit_authorized_submission_once(
|
||||
self,
|
||||
commit: AuthorizedSubmissionCommit,
|
||||
) -> SubmissionCommitResult:
|
||||
return self._delegate.commit_authorized_submission_once(commit)
|
||||
|
||||
|
||||
class _PausingSubmissionRepository:
|
||||
def __init__(
|
||||
self,
|
||||
delegate: SubmissionRepository,
|
||||
*,
|
||||
context_loaded: Event,
|
||||
release_transaction: Event,
|
||||
) -> None:
|
||||
self._delegate = delegate
|
||||
self._context_loaded = context_loaded
|
||||
self._release_transaction = release_transaction
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, scope: SubmissionAttemptScope):
|
||||
with self._delegate.transaction(scope) as transaction:
|
||||
yield _PausingSubmissionTransaction(
|
||||
transaction,
|
||||
context_loaded=self._context_loaded,
|
||||
release_transaction=self._release_transaction,
|
||||
)
|
||||
|
||||
|
||||
class _CountingSubmissionRepository:
|
||||
def __init__(self, delegate: SubmissionRepository) -> None:
|
||||
self._delegate = delegate
|
||||
self._lock = Lock()
|
||||
self.attempt_count = 0
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, scope: SubmissionAttemptScope):
|
||||
with self._lock:
|
||||
self.attempt_count += 1
|
||||
with self._delegate.transaction(scope) as transaction:
|
||||
yield transaction
|
||||
|
||||
|
||||
class _NamedPostgreSQLSession(Session):
|
||||
"""Test-only Session that labels its PostgreSQL backend for lock observation."""
|
||||
|
||||
|
||||
@event.listens_for(_NamedPostgreSQLSession, "after_begin")
|
||||
def _set_postgresql_application_name(session: Session, _transaction, connection) -> None:
|
||||
application_name = session.info.get("application_name")
|
||||
if isinstance(application_name, str):
|
||||
connection.execute(
|
||||
sa.text("SET LOCAL application_name = :application_name"),
|
||||
{"application_name": application_name},
|
||||
)
|
||||
|
||||
|
||||
def _require_postgresql() -> None:
|
||||
if db.engine.dialect.name != "postgresql":
|
||||
pytest.skip("requires the CI PostgreSQL integration database")
|
||||
|
||||
|
||||
def _wait_for_postgresql_lock_wait(
|
||||
session_maker: sessionmaker[Session],
|
||||
*,
|
||||
application_name: str,
|
||||
) -> tuple[int, tuple[int, ...]]:
|
||||
deadline = monotonic() + 10
|
||||
while monotonic() < deadline:
|
||||
with session_maker() as session:
|
||||
row = session.execute(
|
||||
sa.text(
|
||||
"SELECT pid, pg_blocking_pids(pid) AS blocking_pids "
|
||||
"FROM pg_stat_activity "
|
||||
"WHERE datname = current_database() "
|
||||
"AND application_name = :application_name "
|
||||
"AND wait_event_type = 'Lock' "
|
||||
"ORDER BY backend_start DESC LIMIT 1"
|
||||
),
|
||||
{"application_name": application_name},
|
||||
).one_or_none()
|
||||
if row is not None and row.blocking_pids:
|
||||
return row.pid, tuple(row.blocking_pids)
|
||||
sleep(0.05)
|
||||
raise AssertionError("loser PostgreSQL backend did not enter a row-lock wait")
|
||||
|
||||
|
||||
type _TimestampedRecord = HumanInputContact | HumanInputIMBinding | HumanInputIMIdentity | HumanInputIMIntegration
|
||||
|
||||
|
||||
def _set_record_identity(record: _TimestampedRecord, record_id: str, now: UtcTimestamp) -> None:
|
||||
record.id = record_id
|
||||
record.created_at = now.value
|
||||
record.updated_at = now.value
|
||||
|
||||
|
||||
def _seed_scenario(session_maker: sessionmaker[Session]) -> _SeededScenario:
|
||||
now = UtcTimestamp.now()
|
||||
workspace_id = WorkspaceId(str(uuidv7()))
|
||||
account_id = AccountId(str(uuidv7()))
|
||||
contact_id = ContactId(str(uuidv7()))
|
||||
form_ref = FormRef(workspace_id, FormId(str(uuidv7())))
|
||||
grant_id = ApproverGrantId(str(uuidv7()))
|
||||
email_endpoint_id = DeliveryEndpointId(str(uuidv7()))
|
||||
im_endpoint_id = DeliveryEndpointId(str(uuidv7()))
|
||||
integration_id = IntegrationId(str(uuidv7()))
|
||||
identity_id = IMIdentityId(str(uuidv7()))
|
||||
binding_id = IMBindingId(str(uuidv7()))
|
||||
normalized_email = NormalizedEmail(f"submission-{uuidv7()}@example.com")
|
||||
provider_tenant_id = f"provider-tenant-{uuidv7()}"
|
||||
provider_user_id = f"provider-user-{uuidv7()}"
|
||||
workflow_pause_id = str(uuidv7())
|
||||
node_execution_id = str(uuidv7())
|
||||
subject = ContactApprovalSubject(contact_id)
|
||||
grant = ApproverGrant(
|
||||
ref=form_ref.grant(grant_id),
|
||||
subject=subject,
|
||||
subject_key=CanonicalSubjectKey.for_contact(contact_id),
|
||||
matched_sources=(),
|
||||
subject_snapshot=SubjectSnapshot("Concurrent Reviewer", str(normalized_email)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
form = HumanInputForm(
|
||||
ref=form_ref,
|
||||
app_id=AppId(str(uuidv7())),
|
||||
definition=FrozenFormDefinition(
|
||||
form_content="Approve",
|
||||
inputs=(),
|
||||
actions=(FrozenFormAction("approve", "Approve", "primary"),),
|
||||
default_values=FrozenJSONObject.from_mapping({}),
|
||||
node_title="Review",
|
||||
display_in_ui=True,
|
||||
),
|
||||
rendered_content="Approve",
|
||||
node_timeout_at=UtcTimestamp(now.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(now.value + timedelta(hours=2)),
|
||||
kind=HumanInputV2FormKind.RUNTIME,
|
||||
status=HumanInputV2FormStatus.WAITING,
|
||||
workflow_pause_id=workflow_pause_id,
|
||||
node_execution_id=node_execution_id,
|
||||
grants=(grant,),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
email_endpoint = DeliveryEndpoint(
|
||||
ref=grant.ref.endpoint(email_endpoint_id),
|
||||
configuration=EmailEndpointConfiguration(normalized_email),
|
||||
address_hash="a" * 64,
|
||||
access_capability=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
im_endpoint = DeliveryEndpoint(
|
||||
ref=grant.ref.endpoint(im_endpoint_id),
|
||||
configuration=IMEndpointConfiguration(
|
||||
integration_id=integration_id,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
provider_user_id=provider_user_id,
|
||||
),
|
||||
address_hash="b" * 64,
|
||||
access_capability=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
tenant = Tenant(name="Submission Runtime Concurrency")
|
||||
tenant.id = str(workspace_id)
|
||||
account = Account(name="Concurrent Reviewer", email=str(normalized_email), status=AccountStatus.ACTIVE)
|
||||
account.id = str(account_id)
|
||||
membership = TenantAccountJoin(
|
||||
tenant_id=str(workspace_id),
|
||||
account_id=str(account_id),
|
||||
current=False,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
)
|
||||
membership.id = str(uuidv7())
|
||||
contact = HumanInputContact(
|
||||
name="Concurrent Reviewer",
|
||||
normalized_name="concurrent reviewer",
|
||||
identity_source=HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
tenant_id=None,
|
||||
account_id=str(account_id),
|
||||
email=str(normalized_email),
|
||||
normalized_email=str(normalized_email),
|
||||
avatar_file_id=None,
|
||||
)
|
||||
_set_record_identity(contact, str(contact_id), now)
|
||||
integration = HumanInputIMIntegration(
|
||||
provider=IMProvider.SLACK,
|
||||
encrypted_credentials=SlackIMIntegrationEncryptedCredentials(
|
||||
client_id="client-1",
|
||||
encrypted_client_secret="encrypted-client-secret",
|
||||
encrypted_signing_secret="encrypted-signing-secret",
|
||||
encrypted_bot_token="encrypted-bot-token",
|
||||
),
|
||||
tenant_id=str(workspace_id),
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
status=IMIntegrationStatus.CONNECTED,
|
||||
config_version=1,
|
||||
configured_by_account_id=str(account_id),
|
||||
callback_url=None,
|
||||
safe_status_reason=None,
|
||||
last_checked_at=now.value,
|
||||
)
|
||||
_set_record_identity(integration, str(integration_id), now)
|
||||
identity = HumanInputIMIdentity(
|
||||
integration_id=str(integration_id),
|
||||
provider=IMProvider.SLACK,
|
||||
provider_user_id=provider_user_id,
|
||||
display_name="Concurrent Reviewer",
|
||||
normalized_name="concurrent reviewer",
|
||||
email=str(normalized_email),
|
||||
normalized_email=str(normalized_email),
|
||||
raw_payload=IMIdentityRawPayload({}),
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=now.value,
|
||||
)
|
||||
_set_record_identity(identity, str(identity_id), now)
|
||||
binding = HumanInputIMBinding(
|
||||
integration_id=str(integration_id),
|
||||
scope=IMBindingScope.WORKSPACE,
|
||||
scope_id=str(workspace_id),
|
||||
contact_id=str(contact_id),
|
||||
im_identity_id=str(identity_id),
|
||||
provider=IMProvider.SLACK,
|
||||
bound_by_account_id=str(account_id),
|
||||
)
|
||||
_set_record_identity(binding, str(binding_id), now)
|
||||
with session_maker.begin() as session:
|
||||
session.add_all(
|
||||
[
|
||||
tenant,
|
||||
account,
|
||||
membership,
|
||||
contact,
|
||||
integration,
|
||||
identity,
|
||||
binding,
|
||||
form_to_record(form),
|
||||
grant_to_record(grant),
|
||||
endpoint_to_record(email_endpoint),
|
||||
endpoint_to_record(im_endpoint),
|
||||
]
|
||||
)
|
||||
return _SeededScenario(
|
||||
workspace_id=workspace_id,
|
||||
account_id=account_id,
|
||||
contact_id=contact_id,
|
||||
form_ref=form_ref,
|
||||
grant_id=grant_id,
|
||||
email_endpoint_id=email_endpoint_id,
|
||||
im_endpoint_id=im_endpoint_id,
|
||||
integration_id=integration_id,
|
||||
identity_id=identity_id,
|
||||
binding_id=binding_id,
|
||||
normalized_email=normalized_email,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
provider_user_id=provider_user_id,
|
||||
workflow_pause_id=workflow_pause_id,
|
||||
node_execution_id=node_execution_id,
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_scenario(session_maker: sessionmaker[Session], scenario: _SeededScenario) -> None:
|
||||
form_id = str(scenario.form_ref.form_id)
|
||||
with session_maker.begin() as session:
|
||||
session.execute(sa.delete(HumanInputV2FormSubmission).where(HumanInputV2FormSubmission.form_id == form_id))
|
||||
session.execute(sa.delete(HumanInputV2FormAuditEvent).where(HumanInputV2FormAuditEvent.form_id == form_id))
|
||||
session.execute(
|
||||
sa.delete(HumanInputV2FormDeliveryEndpoint).where(HumanInputV2FormDeliveryEndpoint.form_id == form_id)
|
||||
)
|
||||
session.execute(
|
||||
sa.delete(HumanInputV2FormApproverGrant).where(HumanInputV2FormApproverGrant.form_id == form_id)
|
||||
)
|
||||
session.execute(sa.delete(HumanInputV2Form).where(HumanInputV2Form.id == form_id))
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == str(scenario.integration_id))
|
||||
)
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id == str(scenario.integration_id))
|
||||
)
|
||||
session.execute(
|
||||
sa.delete(HumanInputIMIntegration).where(HumanInputIMIntegration.id == str(scenario.integration_id))
|
||||
)
|
||||
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(scenario.contact_id)))
|
||||
session.execute(
|
||||
sa.delete(TenantAccountJoin).where(
|
||||
TenantAccountJoin.tenant_id == str(scenario.workspace_id),
|
||||
TenantAccountJoin.account_id == str(scenario.account_id),
|
||||
)
|
||||
)
|
||||
session.execute(sa.delete(Account).where(Account.id == str(scenario.account_id)))
|
||||
session.execute(sa.delete(Tenant).where(Tenant.id == str(scenario.workspace_id)))
|
||||
|
||||
|
||||
def _email_proof(scenario: _SeededScenario) -> VerifiedEmailOTPProof:
|
||||
return VerifiedEmailOTPProof(
|
||||
challenge_ref=scenario.form_ref.grant(scenario.grant_id).challenge(OTPChallengeId(str(uuidv7()))),
|
||||
subject=ContactOTPSubject(scenario.contact_id),
|
||||
normalized_email=scenario.normalized_email,
|
||||
verified_at=UtcTimestamp.now(),
|
||||
)
|
||||
|
||||
|
||||
def _im_proof(scenario: _SeededScenario) -> VerifiedIMIdentityProof:
|
||||
return VerifiedIMIdentityProof(
|
||||
integration_id=scenario.integration_id,
|
||||
identity_id=scenario.identity_id,
|
||||
binding_id=scenario.binding_id,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id=scenario.provider_tenant_id,
|
||||
provider_user_id=scenario.provider_user_id,
|
||||
)
|
||||
|
||||
|
||||
def _command(scenario: _SeededScenario, *, proof: object, endpoint_id: DeliveryEndpointId) -> SubmitFormCommand:
|
||||
return SubmitFormCommand(
|
||||
scope=SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, endpoint_id),
|
||||
proof=proof,
|
||||
selected_action_id="approve",
|
||||
input_snapshot=FrozenJSONObject.from_mapping({"comment": "approved"}),
|
||||
canonical_values=FrozenJSONObject.from_mapping({"comment": "approved"}),
|
||||
submission_id=SubmissionId(str(uuidv7())),
|
||||
authorization_audit_event_id=AuditEventId(str(uuidv7())),
|
||||
rejection_audit_event_id=AuditEventId(str(uuidv7())),
|
||||
resume_identity=WorkflowResumeIdentity(
|
||||
workspace_id=scenario.form_ref.workspace_id,
|
||||
form_id=scenario.form_ref.form_id,
|
||||
workflow_pause_id=scenario.workflow_pause_id,
|
||||
node_execution_id=scenario.node_execution_id,
|
||||
),
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
|
||||
|
||||
def test_context_load_uses_one_snapshot_across_contact_membership_and_im_queries(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
scenario = _seed_scenario(session_maker)
|
||||
repository = SQLAlchemySubmissionRepository(session_maker)
|
||||
scope = SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, scenario.im_endpoint_id)
|
||||
proof = _im_proof(scenario)
|
||||
contact_query_finished = Event()
|
||||
mutation_committed = Event()
|
||||
loader_thread_id: list[int] = []
|
||||
|
||||
def pause_after_contact_query(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
normalized_statement = " ".join(statement.lower().split())
|
||||
if (
|
||||
loader_thread_id
|
||||
and get_ident() == loader_thread_id[0]
|
||||
and normalized_statement.startswith("select")
|
||||
and "from human_input_contacts" in normalized_statement
|
||||
):
|
||||
contact_query_finished.set()
|
||||
assert mutation_committed.wait(timeout=10)
|
||||
|
||||
def load_context() -> AuthorizationContext:
|
||||
loader_thread_id.append(get_ident())
|
||||
with repository.transaction(scope) as transaction:
|
||||
return transaction.load_authorization_context(proof=proof)
|
||||
|
||||
event.listen(db.engine, "after_cursor_execute", pause_after_contact_query)
|
||||
future = None
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(load_context)
|
||||
assert contact_query_finished.wait(timeout=10)
|
||||
with session_maker.begin() as mutation_session:
|
||||
mutation_session.execute(
|
||||
sa.delete(TenantAccountJoin).where(
|
||||
TenantAccountJoin.tenant_id == str(scenario.workspace_id),
|
||||
TenantAccountJoin.account_id == str(scenario.account_id),
|
||||
)
|
||||
)
|
||||
mutation_session.execute(
|
||||
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.id == str(scenario.binding_id))
|
||||
)
|
||||
mutation_committed.set()
|
||||
context = future.result(timeout=10)
|
||||
|
||||
assert context.current_contact is not None
|
||||
assert context.current_contact.workspace_available is True
|
||||
assert context.current_im_binding is not None
|
||||
assert context.current_im_binding.binding_id == scenario.binding_id
|
||||
finally:
|
||||
mutation_committed.set()
|
||||
event.remove(db.engine, "after_cursor_execute", pause_after_contact_query)
|
||||
if future is not None and not future.done():
|
||||
future.result(timeout=10)
|
||||
_cleanup_scenario(session_maker, scenario)
|
||||
|
||||
|
||||
def test_row_lock_serialization_loser_retries_and_observes_completed_form(flask_req_ctx) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
scenario = _seed_scenario(session_maker)
|
||||
resume_port = _RecordingResumePort()
|
||||
winner_context_loaded = Event()
|
||||
release_winner = Event()
|
||||
winner_repository = _PausingSubmissionRepository(
|
||||
SQLAlchemySubmissionRepository(session_maker),
|
||||
context_loaded=winner_context_loaded,
|
||||
release_transaction=release_winner,
|
||||
)
|
||||
loser_application_name = f"submission-serialization-loser-{uuidv7()}"
|
||||
loser_session_maker = sessionmaker(
|
||||
bind=db.engine,
|
||||
class_=_NamedPostgreSQLSession,
|
||||
expire_on_commit=False,
|
||||
info={"application_name": loser_application_name},
|
||||
)
|
||||
loser_repository = _CountingSubmissionRepository(SQLAlchemySubmissionRepository(loser_session_maker))
|
||||
winner_command = _command(
|
||||
scenario,
|
||||
proof=_email_proof(scenario),
|
||||
endpoint_id=scenario.email_endpoint_id,
|
||||
)
|
||||
loser_command = _command(
|
||||
scenario,
|
||||
proof=_im_proof(scenario),
|
||||
endpoint_id=scenario.im_endpoint_id,
|
||||
)
|
||||
|
||||
def submit(repository: SubmissionRepository, command: SubmitFormCommand) -> SubmitFormResult:
|
||||
return SubmitHumanInputFormHandler(repository, resume_port).handle(command)
|
||||
|
||||
winner_future = None
|
||||
loser_future = None
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
winner_future = executor.submit(submit, winner_repository, winner_command)
|
||||
assert winner_context_loaded.wait(timeout=10)
|
||||
loser_future = executor.submit(submit, loser_repository, loser_command)
|
||||
try:
|
||||
loser_pid, blocking_pids = _wait_for_postgresql_lock_wait(
|
||||
session_maker,
|
||||
application_name=loser_application_name,
|
||||
)
|
||||
assert loser_pid not in blocking_pids
|
||||
assert blocking_pids
|
||||
finally:
|
||||
release_winner.set()
|
||||
winner_result = winner_future.result(timeout=10)
|
||||
loser_result = loser_future.result(timeout=10)
|
||||
|
||||
assert winner_result.status is SubmitFormResultStatus.SUBMITTED
|
||||
assert winner_result.resume_enqueued is True
|
||||
assert loser_result.status is SubmitFormResultStatus.ALREADY_COMPLETED
|
||||
assert loser_result.resume_enqueued is False
|
||||
assert loser_repository.attempt_count == 2
|
||||
assert len(resume_port.identities) == 1
|
||||
with session_maker() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputV2FormSubmission.id)).where(
|
||||
HumanInputV2FormSubmission.form_id == str(scenario.form_ref.form_id)
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputV2FormAuditEvent.id)).where(
|
||||
HumanInputV2FormAuditEvent.form_id == str(scenario.form_ref.form_id)
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
session.get_one(HumanInputV2Form, str(scenario.form_ref.form_id)).status
|
||||
is HumanInputV2FormStatus.SUBMITTED
|
||||
)
|
||||
finally:
|
||||
release_winner.set()
|
||||
if winner_future is not None and not winner_future.done():
|
||||
winner_future.result(timeout=10)
|
||||
if loser_future is not None and not loser_future.done():
|
||||
loser_future.result(timeout=10)
|
||||
_cleanup_scenario(session_maker, scenario)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("proof_kind", ["email", "im"])
|
||||
def test_loaded_context_remains_authoritative_after_identity_change(flask_req_ctx, proof_kind: str) -> None:
|
||||
_require_postgresql()
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
scenario = _seed_scenario(session_maker)
|
||||
proof = _email_proof(scenario) if proof_kind == "email" else _im_proof(scenario)
|
||||
endpoint_id = scenario.email_endpoint_id if proof_kind == "email" else scenario.im_endpoint_id
|
||||
scope = SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, endpoint_id)
|
||||
|
||||
try:
|
||||
with SQLAlchemySubmissionRepository(session_maker).transaction(scope) as transaction:
|
||||
context = transaction.load_authorization_context(proof=proof)
|
||||
with session_maker.begin() as mutation_session:
|
||||
if proof_kind == "email":
|
||||
mutation_session.execute(
|
||||
sa.update(HumanInputContact)
|
||||
.where(HumanInputContact.id == str(scenario.contact_id))
|
||||
.values(email="changed@example.com", normalized_email="changed@example.com")
|
||||
)
|
||||
else:
|
||||
mutation_session.execute(
|
||||
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.id == str(scenario.binding_id))
|
||||
)
|
||||
decision = SubmissionAuthorizer.authorize(
|
||||
context=context,
|
||||
proof=proof,
|
||||
selected_action_id="approve",
|
||||
now=UtcTimestamp.now(),
|
||||
)
|
||||
assert decision.authorized is not None
|
||||
result = transaction.commit_authorized_submission_once(
|
||||
AuthorizedSubmissionCommit(
|
||||
submission_id=SubmissionId(str(uuidv7())),
|
||||
authorization_audit_event_id=AuditEventId(str(uuidv7())),
|
||||
authorized=decision.authorized,
|
||||
input_snapshot=FrozenJSONObject.from_mapping({"comment": "approved"}),
|
||||
canonical_values=FrozenJSONObject.from_mapping({"comment": "approved"}),
|
||||
)
|
||||
)
|
||||
|
||||
assert result.status is SubmissionCommitStatus.COMMITTED
|
||||
with session_maker() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count(HumanInputV2FormSubmission.id)).where(
|
||||
HumanInputV2FormSubmission.form_id == str(scenario.form_ref.form_id)
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
finally:
|
||||
_cleanup_scenario(session_maker, scenario)
|
||||
@@ -0,0 +1,116 @@
|
||||
from collections.abc import Iterator
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
||||
from sqlalchemy import Engine, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
||||
|
||||
from models.types import FrozenPydanticModelColumn
|
||||
|
||||
|
||||
class _ConcretePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class _EmailDestination(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
channel: Literal["email"] = "email"
|
||||
address: str
|
||||
|
||||
|
||||
class _SlackDestination(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
|
||||
|
||||
channel: Literal["slack"] = "slack"
|
||||
channel_id: str
|
||||
|
||||
|
||||
type _Destination = Annotated[_EmailDestination | _SlackDestination, Field(discriminator="channel")]
|
||||
|
||||
|
||||
class _Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class _PydanticJSONRecord(_Base):
|
||||
__tablename__ = "integration_pydantic_json_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True, autoincrement=True)
|
||||
concrete: Mapped[_ConcretePayload] = mapped_column(
|
||||
FrozenPydanticModelColumn(
|
||||
_ConcretePayload,
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
destination: Mapped[_Destination] = mapped_column(
|
||||
FrozenPydanticModelColumn(
|
||||
TypeAdapter(_Destination),
|
||||
model_types=(_EmailDestination, _SlackDestination),
|
||||
),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pydantic_json_engine(db_session_with_containers: Session) -> Engine:
|
||||
bind = db_session_with_containers.get_bind()
|
||||
assert isinstance(bind, Engine)
|
||||
return bind
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pydantic_json_schema(pydantic_json_engine: Engine) -> Iterator[None]:
|
||||
_Base.metadata.create_all(pydantic_json_engine)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_Base.metadata.drop_all(pydantic_json_engine)
|
||||
|
||||
|
||||
def test_real_database_stores_single_encoded_json_and_round_trips_a_concrete_model(
|
||||
pydantic_json_engine: Engine,
|
||||
) -> None:
|
||||
with Session(pydantic_json_engine) as session:
|
||||
record = _PydanticJSONRecord(
|
||||
concrete=_ConcretePayload(name="container", count=3),
|
||||
destination=_EmailDestination(address="reviewer@example.com"),
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
record_id = record.id
|
||||
|
||||
with Session(pydantic_json_engine) as session:
|
||||
stored_json = session.execute(
|
||||
sa.text("SELECT concrete FROM integration_pydantic_json_records WHERE id = :record_id"),
|
||||
{"record_id": record_id},
|
||||
).scalar_one()
|
||||
restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id))
|
||||
|
||||
assert stored_json == '{"name":"container","count":3}'
|
||||
assert restored is not None
|
||||
assert restored.concrete == _ConcretePayload(name="container", count=3)
|
||||
assert isinstance(restored.concrete, _ConcretePayload)
|
||||
|
||||
|
||||
def test_real_database_restores_the_discriminated_union_subtype(pydantic_json_engine: Engine) -> None:
|
||||
with Session(pydantic_json_engine) as session:
|
||||
record = _PydanticJSONRecord(
|
||||
concrete=_ConcretePayload(name="container", count=4),
|
||||
destination=_SlackDestination(channel_id="C123"),
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
record_id = record.id
|
||||
|
||||
with Session(pydantic_json_engine) as session:
|
||||
restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id))
|
||||
|
||||
assert restored is not None
|
||||
assert restored.destination == _SlackDestination(channel_id="C123")
|
||||
assert isinstance(restored.destination, _SlackDestination)
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.common import human_input_v2_contracts as contracts
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload
|
||||
from controllers.common.human_input_v2_contracts import (
|
||||
BatchGetContactsQuery,
|
||||
ExternalContactCreateRequest,
|
||||
FormAccessRequestResponse,
|
||||
HumanInputV2ServiceFormSubmitRequest,
|
||||
IMIntegration,
|
||||
IMSyncResultItem,
|
||||
IMSyncRun,
|
||||
ListIMIdentitiesQuery,
|
||||
MessageTemplateTestRequest,
|
||||
NodeDataMigrationFailureResponse,
|
||||
NodeDataMigrationPayload,
|
||||
NodeDataMigrationResponse,
|
||||
UpdateIMIntegrationRequest,
|
||||
)
|
||||
|
||||
|
||||
def test_request_dto_coerces_enum_values_and_forbids_extra_fields() -> None:
|
||||
request_body = MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}})
|
||||
|
||||
assert request_body.channel.value == "email"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}, "unexpected": True})
|
||||
|
||||
|
||||
def test_update_im_integration_cas_fields_are_both_present_or_both_absent() -> None:
|
||||
credentials = {
|
||||
"provider": "feishu",
|
||||
"app_id": "app-id",
|
||||
"app_secret": "app-secret",
|
||||
}
|
||||
|
||||
create_request = UpdateIMIntegrationRequest.model_validate({"credentials": credentials})
|
||||
update_request = UpdateIMIntegrationRequest.model_validate(
|
||||
{
|
||||
"credentials": credentials,
|
||||
"expected_integration_id": "integration-id",
|
||||
"expected_config_version": 3,
|
||||
}
|
||||
)
|
||||
|
||||
assert create_request.expected_integration_id is None
|
||||
assert create_request.expected_config_version is None
|
||||
assert update_request.expected_integration_id == "integration-id"
|
||||
assert update_request.expected_config_version == 3
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
UpdateIMIntegrationRequest.model_validate(
|
||||
{
|
||||
"credentials": credentials,
|
||||
"expected_integration_id": "integration-id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_im_integration_and_sync_run_expose_captured_revision() -> None:
|
||||
integration = IMIntegration.model_validate(
|
||||
{
|
||||
"provider": "feishu",
|
||||
"status": "connected",
|
||||
"integration_id": "integration-id",
|
||||
"config_version": 4,
|
||||
}
|
||||
)
|
||||
sync_run = IMSyncRun.model_validate(
|
||||
{
|
||||
"id": "run-id",
|
||||
"status": "queued",
|
||||
"result_counts": {"added": 0, "not_matched": 0, "failed": 0, "removed": 0, "skipped": 0},
|
||||
"provider": "feishu",
|
||||
"integration_id": "integration-id",
|
||||
"integration_config_version": 4,
|
||||
}
|
||||
)
|
||||
|
||||
assert integration.integration_id == "integration-id"
|
||||
assert integration.config_version == 4
|
||||
assert sync_run.integration_id == "integration-id"
|
||||
assert sync_run.integration_config_version == 4
|
||||
|
||||
|
||||
def test_delete_im_integration_requires_complete_cas_token() -> None:
|
||||
delete_query_model = getattr(contracts, "DeleteIMIntegrationQuery", None)
|
||||
assert delete_query_model is not None
|
||||
|
||||
query = delete_query_model.model_validate(
|
||||
{
|
||||
"expected_integration_id": "integration-id",
|
||||
"expected_config_version": "4",
|
||||
}
|
||||
)
|
||||
|
||||
assert query.expected_integration_id == "integration-id"
|
||||
assert query.expected_config_version == 4
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
delete_query_model.model_validate({"expected_integration_id": "integration-id"})
|
||||
|
||||
|
||||
def test_external_contact_avatar_is_optional() -> None:
|
||||
request_body = ExternalContactCreateRequest.model_validate(
|
||||
{
|
||||
"name": "External Approver",
|
||||
"email": "approver@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
assert request_body.avatar is None
|
||||
|
||||
|
||||
def test_node_data_migration_contract_matches_frontend_adapter_boundary() -> None:
|
||||
request_body = NodeDataMigrationPayload.model_validate(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "node-1",
|
||||
"node_data": {
|
||||
"version": "1",
|
||||
"future_legacy_field": "ignored",
|
||||
},
|
||||
"future_envelope_field": "ignored",
|
||||
}
|
||||
],
|
||||
"future_request_field": "ignored",
|
||||
}
|
||||
)
|
||||
|
||||
assert request_body.nodes[0].node_data.version == "1"
|
||||
assert not hasattr(request_body.nodes[0].node_data, "future_legacy_field")
|
||||
assert set(NodeDataMigrationResponse.model_json_schema()["properties"]) == {"data"}
|
||||
assert contracts.LegacyHITLv1NodeData.model_json_schema()["properties"]["version"]["const"] == "1"
|
||||
|
||||
failure = NodeDataMigrationFailureResponse.model_validate(
|
||||
{
|
||||
"message": "Migration failed",
|
||||
"status": 400,
|
||||
"blockers": [
|
||||
{
|
||||
"node_id": "node-1",
|
||||
"node_title": "Approval",
|
||||
"code": "unresolved-member",
|
||||
"method_id": "email-1",
|
||||
"value": "member-1",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert failure.blockers[0].code == "unresolved-member"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
NodeDataMigrationPayload.model_validate(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "node-1",
|
||||
"node_data": {"version": "2"},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
NodeDataMigrationPayload.model_validate(
|
||||
{
|
||||
"nodes": [
|
||||
{"node_id": "node-1", "node_data": {"version": "1"}},
|
||||
{"node_id": "node-1", "node_data": {"version": "1"}},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_v1_and_v2_submit_payloads_are_independent() -> None:
|
||||
v2_submit_model = getattr(contracts, "HumanInputV2FormSubmitRequest", None)
|
||||
assert v2_submit_model is not None
|
||||
|
||||
v1_schema = HumanInputFormSubmitPayload.model_json_schema()
|
||||
v2_schema = v2_submit_model.model_json_schema()
|
||||
|
||||
assert "challenge_token" not in v1_schema["properties"]
|
||||
assert "otp_code" not in v1_schema["properties"]
|
||||
assert "challenge_token" in v2_schema["properties"]
|
||||
assert "otp_code" in v2_schema["properties"]
|
||||
|
||||
|
||||
def test_service_v2_submit_payload_does_not_accept_public_otp_fields() -> None:
|
||||
payload = {
|
||||
"user": "end-user",
|
||||
"inputs": {},
|
||||
"action": "approve",
|
||||
}
|
||||
|
||||
request_body = HumanInputV2ServiceFormSubmitRequest.model_validate(payload)
|
||||
|
||||
assert request_body.user == "end-user"
|
||||
with pytest.raises(ValidationError):
|
||||
HumanInputV2ServiceFormSubmitRequest.model_validate({**payload, "otp_code": "123456"})
|
||||
|
||||
|
||||
def test_public_v2_submit_requires_complete_email_proof() -> None:
|
||||
submit_model = contracts.HumanInputV2FormSubmitRequest
|
||||
base_payload = {"inputs": {}, "action": "approve"}
|
||||
|
||||
submit_model.model_validate(base_payload)
|
||||
submit_model.model_validate(
|
||||
{
|
||||
**base_payload,
|
||||
"challenge_token": "challenge-token",
|
||||
"otp_code": "123456",
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
submit_model.model_validate({**base_payload, "otp_code": "123456"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
submit_model.model_validate({**base_payload, "challenge_token": "challenge-token"})
|
||||
|
||||
|
||||
def test_access_request_response_exposes_resend_cooldown() -> None:
|
||||
schema = FormAccessRequestResponse.model_json_schema()
|
||||
|
||||
assert "resend_after_seconds" in schema["properties"]
|
||||
|
||||
|
||||
def test_batch_get_contacts_query_forbids_extra_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
BatchGetContactsQuery.model_validate({"contact_ids": ["contact-1"], "unexpected": True})
|
||||
|
||||
|
||||
def test_sync_result_item_has_no_contact_type() -> None:
|
||||
schema = IMSyncResultItem.model_json_schema()
|
||||
|
||||
assert "type" not in schema["properties"]
|
||||
|
||||
|
||||
def test_im_identity_search_includes_provider_user_id() -> None:
|
||||
keyword_description = ListIMIdentitiesQuery.model_json_schema()["properties"]["keyword"]["description"]
|
||||
|
||||
assert "provider user ID" in keyword_description
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotImplemented
|
||||
|
||||
from controllers.service_api.app import human_input_form as human_input_form_module
|
||||
|
||||
|
||||
def test_v1_and_v2_service_form_routes_are_distinct() -> None:
|
||||
urls = {
|
||||
url
|
||||
for _resource, resource_urls, _route_doc, _kwargs in human_input_form_module.service_api_ns.resources
|
||||
for url in resource_urls
|
||||
}
|
||||
v2_form_api = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
|
||||
|
||||
assert "/form/human_input/<string:form_token>" in urls
|
||||
assert "/form/human-input/<string:form_token>" in urls
|
||||
assert v2_form_api is not None
|
||||
assert human_input_form_module.WorkflowHumanInputFormApi is not v2_form_api
|
||||
|
||||
|
||||
def test_v2_service_get_requires_query_user(app: Flask) -> None:
|
||||
resource_cls = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
|
||||
assert resource_cls is not None
|
||||
get_method = resource_cls.get
|
||||
user_doc = getattr(get_method, "__apidoc__", {})["params"]["user"]
|
||||
|
||||
assert user_doc["required"] is True
|
||||
|
||||
with app.test_request_context("/v1/form/human-input/token?user=end-user", method="GET"):
|
||||
with pytest.raises(NotImplemented):
|
||||
unwrap(get_method)(
|
||||
resource_cls(),
|
||||
app_model=object(),
|
||||
end_user=object(),
|
||||
form_token="token",
|
||||
)
|
||||
|
||||
|
||||
def test_v2_service_post_uses_independent_payload(app: Flask) -> None:
|
||||
resource_cls = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
|
||||
assert resource_cls is not None
|
||||
post_method = resource_cls.post
|
||||
|
||||
with app.test_request_context(
|
||||
"/v1/form/human-input/token",
|
||||
method="POST",
|
||||
json={"user": "end-user", "inputs": {}, "action": "approve"},
|
||||
):
|
||||
with pytest.raises(NotImplemented):
|
||||
unwrap(post_method)(
|
||||
resource_cls(),
|
||||
app_model=object(),
|
||||
end_user=object(),
|
||||
form_token="token",
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import unwrap
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotImplemented
|
||||
|
||||
from controllers.web import human_input_form as human_input_form_v1
|
||||
from controllers.web import human_input_form_access_request as human_input_form_v2
|
||||
|
||||
|
||||
def _registered_urls(module) -> set[str]:
|
||||
return {
|
||||
url
|
||||
for _resource, resource_urls, _route_doc, _kwargs in module.web_ns.resources
|
||||
for url in resource_urls
|
||||
}
|
||||
|
||||
|
||||
def test_v1_and_v2_public_form_routes_are_distinct() -> None:
|
||||
urls = _registered_urls(human_input_form_v2)
|
||||
v2_form_api = getattr(human_input_form_v2, "HumanInputV2FormApi", None)
|
||||
|
||||
assert "/form/human_input/<string:form_token>" in urls
|
||||
assert "/form/human_input/<string:form_token>/upload-token" in urls
|
||||
assert "/form/human-input/<string:form_token>" in urls
|
||||
assert "/form/human-input/<string:form_token>/access-request" in urls
|
||||
assert "/form/human-input/<string:form_token>/upload-token" in urls
|
||||
|
||||
assert v2_form_api is not None
|
||||
assert human_input_form_v1.HumanInputFormApi is not v2_form_api
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource_name", "method_name", "path", "json_body"),
|
||||
[
|
||||
("HumanInputV2FormApi", "get", "/api/form/human-input/token", None),
|
||||
(
|
||||
"HumanInputV2FormApi",
|
||||
"post",
|
||||
"/api/form/human-input/token",
|
||||
{"inputs": {}, "action": "approve"},
|
||||
),
|
||||
(
|
||||
"HumanInputV2FormUploadTokenApi",
|
||||
"post",
|
||||
"/api/form/human-input/token/upload-token",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"FormAccessRequestApi",
|
||||
"post",
|
||||
"/api/form/human-input/token/access-request",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v2_public_form_routes_are_exposed_as_independent_stubs(
|
||||
app: Flask,
|
||||
resource_name: str,
|
||||
method_name: str,
|
||||
path: str,
|
||||
json_body: dict[str, object] | None,
|
||||
) -> None:
|
||||
resource_cls = getattr(human_input_form_v2, resource_name, None)
|
||||
assert resource_cls is not None
|
||||
method = getattr(resource_cls, method_name)
|
||||
|
||||
with app.test_request_context(path, method=method_name.upper(), json=json_body):
|
||||
with pytest.raises(NotImplemented):
|
||||
unwrap(method)(resource_cls(), form_token="token")
|
||||
@@ -337,14 +337,14 @@ class TestWorkflowBasedAppRunner:
|
||||
graph_runtime_state.register_paused_node("node-1")
|
||||
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
|
||||
|
||||
emails: list[dict] = []
|
||||
form_deliveries: list[dict] = []
|
||||
|
||||
class _Dispatch:
|
||||
def apply_async(self, *, kwargs, queue):
|
||||
emails.append({"kwargs": kwargs, "queue": queue})
|
||||
form_deliveries.append({"kwargs": kwargs, "queue": queue})
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow_app_runner.dispatch_human_input_email_task",
|
||||
"core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task",
|
||||
_Dispatch(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -373,7 +373,7 @@ class TestWorkflowBasedAppRunner:
|
||||
assert any(isinstance(event, QueueWorkflowSucceededEvent) for event, _ in published)
|
||||
paused_event = next(event for event, _ in published if isinstance(event, QueueWorkflowPausedEvent))
|
||||
assert paused_event.paused_nodes == ["node-1"]
|
||||
assert emails
|
||||
assert form_deliveries
|
||||
|
||||
def test_handle_graph_aborted_publishes_stopped_terminal(self):
|
||||
published: list[object] = []
|
||||
|
||||
@@ -34,7 +34,7 @@ class _DummyWorkflowEntry:
|
||||
self.graph_engine = _DummyGraphEngine()
|
||||
|
||||
|
||||
def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_handle_pause_event_enqueues_form_delivery_task(monkeypatch: pytest.MonkeyPatch):
|
||||
queue_manager = _DummyQueueManager()
|
||||
runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id")
|
||||
workflow_entry = _DummyWorkflowEntry()
|
||||
@@ -42,7 +42,7 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
|
||||
graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review")
|
||||
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={})
|
||||
|
||||
email_task = MagicMock()
|
||||
form_delivery_task = MagicMock()
|
||||
enriched_reason = HumanInputRequired(
|
||||
form_id="form-123",
|
||||
form_content="content",
|
||||
@@ -55,12 +55,12 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
|
||||
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
|
||||
lambda **_: [enriched_reason],
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task)
|
||||
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task", form_delivery_task)
|
||||
|
||||
runner._handle_event(workflow_entry, event)
|
||||
|
||||
email_task.apply_async.assert_called_once()
|
||||
kwargs = email_task.apply_async.call_args.kwargs["kwargs"]
|
||||
form_delivery_task.apply_async.assert_called_once()
|
||||
kwargs = form_delivery_task.apply_async.call_args.kwargs["kwargs"]
|
||||
assert kwargs["form_id"] == "form-123"
|
||||
assert kwargs["node_title"] == "Review"
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Behavior tests for the Human Input v2 form aggregate and frozen child facts."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ApproverGrant,
|
||||
CanonicalSubjectKey,
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpoint,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointPlan,
|
||||
EndpointAccessCapability,
|
||||
FormInactiveReason,
|
||||
FormRef,
|
||||
FormSnapshotIdentifierFactory,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
InvalidApproverGrantError,
|
||||
InvalidSelectedActionError,
|
||||
MatchedRecipientSource,
|
||||
RecipientSourceKind,
|
||||
ResolvedApprovalPlan,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
UploadCapability,
|
||||
UploadFileAssociation,
|
||||
)
|
||||
from core.human_input_v2.entities import (
|
||||
HumanInputDeliveryAttemptStatus,
|
||||
HumanInputV2FormKind,
|
||||
HumanInputV2FormStatus,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
FormId,
|
||||
NormalizedEmail,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
|
||||
|
||||
|
||||
class _SequentialIdentifierFactory(FormSnapshotIdentifierFactory):
|
||||
def __init__(self) -> None:
|
||||
self._grant_number = 0
|
||||
self._endpoint_number = 0
|
||||
|
||||
def new_grant_id(self) -> ApproverGrantId:
|
||||
self._grant_number += 1
|
||||
return ApproverGrantId(f"grant-{self._grant_number}")
|
||||
|
||||
def new_endpoint_id(self) -> DeliveryEndpointId:
|
||||
self._endpoint_number += 1
|
||||
return DeliveryEndpointId(f"endpoint-{self._endpoint_number}")
|
||||
|
||||
|
||||
def _definition() -> FrozenFormDefinition:
|
||||
return FrozenFormDefinition(
|
||||
form_content="Please approve {{ request }}",
|
||||
inputs=(
|
||||
FrozenJSONObject.from_mapping(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"output_variable_name": "reason",
|
||||
"default": {"type": "constant", "selector": [], "value": "ok"},
|
||||
}
|
||||
),
|
||||
),
|
||||
actions=(
|
||||
FrozenFormAction(id="approve", title="Approve", button_style="primary"),
|
||||
FrozenFormAction(id="reject", title="Reject", button_style="default"),
|
||||
),
|
||||
default_values=FrozenJSONObject.from_mapping({"reason": "ok", "metadata": {"labels": ["finance", "urgent"]}}),
|
||||
node_title="Review",
|
||||
display_in_ui=True,
|
||||
)
|
||||
|
||||
|
||||
def _approver(email: str, *, source_position: int = 0) -> ResolvedApprover:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
return ResolvedApprover(
|
||||
subject=EmailAddressApprovalSubject(normalized_email),
|
||||
subject_key=CanonicalSubjectKey.for_email(normalized_email),
|
||||
matched_sources=(MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, source_position, email),),
|
||||
subject_snapshot=SubjectSnapshot(display_name=None, email=email),
|
||||
endpoints=(EmailEndpointPlan(normalized_email),),
|
||||
)
|
||||
|
||||
|
||||
def _form(*, status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING) -> HumanInputForm:
|
||||
grant = ApproverGrant.from_resolved_approver(
|
||||
grant_id=ApproverGrantId("grant-1"),
|
||||
form_ref=_FORM_REF,
|
||||
approver=_approver("reviewer@example.com"),
|
||||
now=_NOW,
|
||||
)
|
||||
return HumanInputForm(
|
||||
ref=_FORM_REF,
|
||||
app_id=AppId("app-1"),
|
||||
definition=_definition(),
|
||||
rendered_content="Rendered",
|
||||
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
|
||||
kind=HumanInputV2FormKind.RUNTIME,
|
||||
status=status,
|
||||
workflow_pause_id="pause-1",
|
||||
node_execution_id="node-execution-1",
|
||||
grants=(grant,),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_frozen_structured_values_are_recursive_and_round_trip_without_mutable_domain_dicts() -> None:
|
||||
definition = _definition()
|
||||
|
||||
primitive = definition.default_values.to_mapping()
|
||||
|
||||
assert primitive == {"metadata": {"labels": ["finance", "urgent"]}, "reason": "ok"}
|
||||
primitive["reason"] = "changed"
|
||||
assert definition.default_values.to_mapping()["reason"] == "ok"
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
definition.node_title = "Changed"
|
||||
with pytest.raises(TypeError, match="unsupported JSON value"):
|
||||
FrozenJSONObject.from_mapping({"invalid": object()})
|
||||
|
||||
|
||||
def test_grant_endpoint_token_upload_and_delivery_facts_remain_distinct_capabilities() -> None:
|
||||
grant = ApproverGrant.from_resolved_approver(
|
||||
grant_id=ApproverGrantId("grant-1"),
|
||||
form_ref=_FORM_REF,
|
||||
approver=_approver("reviewer@example.com"),
|
||||
now=_NOW,
|
||||
)
|
||||
endpoint = DeliveryEndpoint.from_plan(
|
||||
endpoint_id=DeliveryEndpointId("endpoint-1"),
|
||||
grant_ref=grant.ref,
|
||||
endpoint_plan=EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
|
||||
access_capability=EndpointAccessCapability(
|
||||
endpoint_ref=grant.ref.endpoint(DeliveryEndpointId("endpoint-1")),
|
||||
token_hash="a" * 64,
|
||||
),
|
||||
now=_NOW,
|
||||
)
|
||||
upload_capability = UploadCapability(
|
||||
id=UploadCapabilityId("upload-capability-1"),
|
||||
endpoint_ref=endpoint.ref,
|
||||
app_id=AppId("app-1"),
|
||||
token_hash="b" * 64,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
upload = UploadFileAssociation(
|
||||
id=UploadFileAssociationId("upload-association-1"),
|
||||
capability_ref=upload_capability.ref,
|
||||
upload_file_id="file-1",
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
failed_attempt = DeliveryAttempt(
|
||||
id=DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref=endpoint.ref,
|
||||
attempt_number=1,
|
||||
status=HumanInputDeliveryAttemptStatus.FAILED,
|
||||
scheduled_at=_NOW,
|
||||
started_at=_NOW,
|
||||
finished_at=_NOW,
|
||||
provider_message_id=None,
|
||||
failure_code="provider_rejected",
|
||||
failure_reason="Recipient unavailable",
|
||||
provider_response=FrozenJSONObject.from_mapping({"status": 400}),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
assert grant.subject_snapshot.email == "reviewer@example.com"
|
||||
assert endpoint.access_capability is not None
|
||||
assert endpoint.access_capability.endpoint_ref == endpoint.ref
|
||||
assert upload.capability_ref.endpoint_ref == endpoint.ref
|
||||
assert failed_attempt.endpoint_ref == endpoint.ref
|
||||
assert not hasattr(endpoint.access_capability, "actor")
|
||||
assert not hasattr(endpoint.access_capability, "verified_proof")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "now", "expected_reason"),
|
||||
[
|
||||
(HumanInputV2FormStatus.SUBMITTED, _NOW, FormInactiveReason.SUBMITTED),
|
||||
(HumanInputV2FormStatus.TIMEOUT, _NOW, FormInactiveReason.TIMED_OUT),
|
||||
(HumanInputV2FormStatus.EXPIRED, _NOW, FormInactiveReason.STATUS_EXPIRED),
|
||||
(
|
||||
HumanInputV2FormStatus.WAITING,
|
||||
UtcTimestamp(_NOW.value + timedelta(hours=3)),
|
||||
FormInactiveReason.GLOBALLY_EXPIRED,
|
||||
),
|
||||
(
|
||||
HumanInputV2FormStatus.WAITING,
|
||||
UtcTimestamp(_NOW.value + timedelta(minutes=90)),
|
||||
FormInactiveReason.TIMED_OUT,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_form_returns_stable_inactive_reasons(
|
||||
status: HumanInputV2FormStatus,
|
||||
now: UtcTimestamp,
|
||||
expected_reason: FormInactiveReason,
|
||||
) -> None:
|
||||
state = _form(status=status).state_at(now)
|
||||
|
||||
assert state.reason is expected_reason
|
||||
|
||||
|
||||
def test_waiting_form_accepts_valid_transition_decision_without_mutating_status() -> None:
|
||||
form = _form()
|
||||
|
||||
decision = form.decide_submission(
|
||||
grant_id=ApproverGrantId("grant-1"),
|
||||
selected_action_id="approve",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert decision.form_ref == _FORM_REF
|
||||
assert decision.grant_id == ApproverGrantId("grant-1")
|
||||
assert decision.selected_action_id == "approve"
|
||||
assert form.status is HumanInputV2FormStatus.WAITING
|
||||
|
||||
|
||||
def test_form_rejects_invalid_grant_and_selected_action_without_mutating_status() -> None:
|
||||
form = _form()
|
||||
|
||||
with pytest.raises(InvalidApproverGrantError):
|
||||
form.decide_submission(
|
||||
grant_id=ApproverGrantId("grant-missing"),
|
||||
selected_action_id="approve",
|
||||
now=_NOW,
|
||||
)
|
||||
with pytest.raises(InvalidSelectedActionError):
|
||||
form.decide_submission(
|
||||
grant_id=ApproverGrantId("grant-1"),
|
||||
selected_action_id="escalate",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert form.status is HumanInputV2FormStatus.WAITING
|
||||
|
||||
|
||||
def test_resolved_plan_maps_deterministically_to_one_grant_per_approver_and_separate_endpoints() -> None:
|
||||
first_approver = _approver("first@example.com", source_position=0)
|
||||
second_email = NormalizedEmail("second@example.com")
|
||||
second_approver = ResolvedApprover(
|
||||
subject=EmailAddressApprovalSubject(second_email),
|
||||
subject_key=CanonicalSubjectKey.for_email(second_email),
|
||||
matched_sources=(
|
||||
MatchedRecipientSource(RecipientSourceKind.DYNAMIC_EMAIL, 1, "node.email"),
|
||||
MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, 2, "second@example.com"),
|
||||
),
|
||||
subject_snapshot=SubjectSnapshot(display_name="Second", email="second@example.com"),
|
||||
endpoints=(EmailEndpointPlan(second_email), EmailEndpointPlan(NormalizedEmail("backup@example.com"))),
|
||||
)
|
||||
plan = ResolvedApprovalPlan(
|
||||
approvers=(first_approver, second_approver),
|
||||
rejected_recipients=(),
|
||||
failure_reason=None,
|
||||
)
|
||||
|
||||
creation = HumanInputForm.create_from_plan(
|
||||
ref=_FORM_REF,
|
||||
app_id=AppId("app-1"),
|
||||
definition=_definition(),
|
||||
rendered_content="Rendered",
|
||||
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
|
||||
kind=HumanInputV2FormKind.RUNTIME,
|
||||
workflow_pause_id="pause-1",
|
||||
node_execution_id="node-execution-1",
|
||||
plan=plan,
|
||||
identifier_factory=_SequentialIdentifierFactory(),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert [grant.id for grant in creation.form.grants] == [
|
||||
ApproverGrantId("grant-1"),
|
||||
ApproverGrantId("grant-2"),
|
||||
]
|
||||
assert [endpoint.id for endpoint in creation.endpoints] == [
|
||||
DeliveryEndpointId("endpoint-1"),
|
||||
DeliveryEndpointId("endpoint-2"),
|
||||
DeliveryEndpointId("endpoint-3"),
|
||||
]
|
||||
assert creation.form.grants[1].matched_sources == second_approver.matched_sources
|
||||
assert [endpoint.grant_ref.grant_id for endpoint in creation.endpoints] == [
|
||||
ApproverGrantId("grant-1"),
|
||||
ApproverGrantId("grant-2"),
|
||||
ApproverGrantId("grant-2"),
|
||||
]
|
||||
|
||||
|
||||
def test_failed_delivery_fact_does_not_change_form_lifecycle() -> None:
|
||||
form = _form()
|
||||
|
||||
before = form.state_at(_NOW)
|
||||
after = form.state_at(_NOW)
|
||||
|
||||
assert before.is_waiting
|
||||
assert after.is_waiting
|
||||
assert form.status is HumanInputV2FormStatus.WAITING
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Boundary and failure-path tests for Form Core immutable domain values."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from math import nan
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ApproverGrant,
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointConfiguration,
|
||||
ConsoleEndpointPlan,
|
||||
ContactApprovalSubject,
|
||||
DeliveryAttempt,
|
||||
DeliveryEndpoint,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointConfiguration,
|
||||
EmailEndpointPlan,
|
||||
EndpointAccessCapability,
|
||||
FormCreation,
|
||||
FormInactiveReason,
|
||||
FormRef,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
FrozenJSONArray,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
IMEndpointConfiguration,
|
||||
IMEndpointPlan,
|
||||
MatchedRecipientSource,
|
||||
RecipientResolutionFailureReason,
|
||||
RecipientSourceKind,
|
||||
ResolvedApprovalPlan,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
UploadCapability,
|
||||
UploadFileAssociation,
|
||||
WebEndpointConfiguration,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
from core.human_input_v2.entities import (
|
||||
HumanInputDeliveryAttemptStatus,
|
||||
HumanInputV2FormKind,
|
||||
HumanInputV2FormStatus,
|
||||
IMProvider,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
ContactId,
|
||||
DeliveryAttemptId,
|
||||
DeliveryEndpointId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UploadCapabilityId,
|
||||
UploadFileAssociationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
|
||||
|
||||
|
||||
def _email_approver(email: str = "reviewer@example.com") -> ResolvedApprover:
|
||||
normalized_email = NormalizedEmail(email)
|
||||
return ResolvedApprover(
|
||||
subject=EmailAddressApprovalSubject(normalized_email),
|
||||
subject_key=CanonicalSubjectKey.for_email(normalized_email),
|
||||
matched_sources=(MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, 0, email),),
|
||||
subject_snapshot=SubjectSnapshot("Reviewer", email),
|
||||
endpoints=(EmailEndpointPlan(normalized_email),),
|
||||
)
|
||||
|
||||
|
||||
def _grant(
|
||||
*,
|
||||
grant_id: str = "grant-1",
|
||||
form_ref: FormRef = _FORM_REF,
|
||||
approver: ResolvedApprover | None = None,
|
||||
) -> ApproverGrant:
|
||||
return ApproverGrant.from_resolved_approver(
|
||||
grant_id=ApproverGrantId(grant_id),
|
||||
form_ref=form_ref,
|
||||
approver=approver or _email_approver(),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _definition() -> FrozenFormDefinition:
|
||||
return FrozenFormDefinition(
|
||||
form_content="Approve",
|
||||
inputs=(),
|
||||
actions=(FrozenFormAction("approve", "Approve", "primary"),),
|
||||
default_values=FrozenJSONObject.from_mapping({}),
|
||||
node_title=None,
|
||||
display_in_ui=None,
|
||||
)
|
||||
|
||||
|
||||
def _form(
|
||||
*,
|
||||
kind: HumanInputV2FormKind = HumanInputV2FormKind.RUNTIME,
|
||||
workflow_pause_id: str | None = "pause-1",
|
||||
node_execution_id: str | None = "execution-1",
|
||||
grants: tuple[ApproverGrant, ...] | list[ApproverGrant] | None = None,
|
||||
status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING,
|
||||
) -> HumanInputForm:
|
||||
return HumanInputForm(
|
||||
ref=_FORM_REF,
|
||||
app_id=AppId("app-1"),
|
||||
definition=_definition(),
|
||||
rendered_content="Rendered",
|
||||
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
|
||||
kind=kind,
|
||||
status=status,
|
||||
workflow_pause_id=workflow_pause_id,
|
||||
node_execution_id=node_execution_id,
|
||||
grants=(_grant(),) if grants is None else grants,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action_id", "title", "button_style"),
|
||||
[("", "Approve", "primary"), ("approve", "", "primary"), ("approve", "Approve", "")],
|
||||
)
|
||||
def test_frozen_form_action_rejects_each_blank_component(action_id: str, title: str, button_style: str) -> None:
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
FrozenFormAction(action_id, title, button_style)
|
||||
|
||||
|
||||
def test_frozen_definition_rejects_mutable_collections_and_duplicate_actions() -> None:
|
||||
action = FrozenFormAction("approve", "Approve", "primary")
|
||||
|
||||
with pytest.raises(TypeError, match="immutable tuples"):
|
||||
FrozenFormDefinition("Approve", [], (action,), FrozenJSONObject.from_mapping({}), None, None)
|
||||
with pytest.raises(TypeError, match="immutable tuples"):
|
||||
FrozenFormDefinition("Approve", (), [action], FrozenJSONObject.from_mapping({}), None, None)
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
FrozenFormDefinition("Approve", (), (action, action), FrozenJSONObject.from_mapping({}), None, None)
|
||||
|
||||
|
||||
def test_recursive_frozen_json_accepts_all_json_shapes_and_rejects_invalid_values() -> None:
|
||||
frozen = FrozenJSONObject.from_mapping(
|
||||
{
|
||||
"none": None,
|
||||
"text": "value",
|
||||
"boolean": True,
|
||||
"integer": 1,
|
||||
"float": 1.5,
|
||||
"array": [False, {"nested": "value"}],
|
||||
}
|
||||
)
|
||||
|
||||
assert frozen.to_mapping()["array"] == [False, {"nested": "value"}]
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
FrozenJSONObject.from_mapping({"invalid": nan})
|
||||
with pytest.raises(TypeError, match="unsupported"):
|
||||
FrozenJSONObject.from_mapping({"invalid": object()})
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
FrozenJSONArray([])
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
FrozenJSONObject([])
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
FrozenJSONObject((("key", 1), ("key", 2)))
|
||||
|
||||
|
||||
def test_direct_frozen_json_construction_rejects_mutable_and_non_finite_nested_values() -> None:
|
||||
nested_array = FrozenJSONArray((1, FrozenJSONObject((("nested", True),))))
|
||||
nested_object = FrozenJSONObject((("array", nested_array),))
|
||||
|
||||
assert nested_object.to_mapping() == {"array": [1, {"nested": True}]}
|
||||
with pytest.raises(TypeError, match="frozen JSON"):
|
||||
FrozenJSONArray(([1],))
|
||||
with pytest.raises(TypeError, match="frozen JSON"):
|
||||
FrozenJSONObject((("nested", {"value": 1}),))
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
FrozenJSONArray((nan,))
|
||||
|
||||
|
||||
def test_grant_rejects_mutable_sources_and_mismatched_subject_key() -> None:
|
||||
approver = _email_approver()
|
||||
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
ApproverGrant(
|
||||
ref=_FORM_REF.grant(ApproverGrantId("grant-1")),
|
||||
subject=approver.subject,
|
||||
subject_key=approver.subject_key,
|
||||
matched_sources=list(approver.matched_sources),
|
||||
subject_snapshot=approver.subject_snapshot,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="subject key"):
|
||||
ApproverGrant(
|
||||
ref=_FORM_REF.grant(ApproverGrantId("grant-1")),
|
||||
subject=ContactApprovalSubject(ContactId("contact-1")),
|
||||
subject_key=CanonicalSubjectKey.for_contact(ContactId("contact-2")),
|
||||
matched_sources=approver.matched_sources,
|
||||
subject_snapshot=approver.subject_snapshot,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_endpoint_factory_maps_each_channel_to_a_typed_configuration() -> None:
|
||||
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
|
||||
plans = (
|
||||
EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
|
||||
IMEndpointPlan(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
provider_user_id="provider-user-1",
|
||||
),
|
||||
WebEndpointPlan(),
|
||||
ConsoleEndpointPlan(),
|
||||
)
|
||||
|
||||
endpoints = tuple(
|
||||
DeliveryEndpoint.from_plan(
|
||||
endpoint_id=DeliveryEndpointId(f"endpoint-{position}"),
|
||||
grant_ref=grant_ref,
|
||||
endpoint_plan=plan,
|
||||
access_capability=None,
|
||||
now=_NOW,
|
||||
)
|
||||
for position, plan in enumerate(plans)
|
||||
)
|
||||
|
||||
assert isinstance(endpoints[0].configuration, EmailEndpointConfiguration)
|
||||
assert isinstance(endpoints[1].configuration, IMEndpointConfiguration)
|
||||
assert isinstance(endpoints[2].configuration, WebEndpointConfiguration)
|
||||
assert isinstance(endpoints[3].configuration, ConsoleEndpointConfiguration)
|
||||
assert len({endpoint.address_hash for endpoint in endpoints}) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_tenant_id", "provider_user_id"),
|
||||
[("", "user-1"), ("tenant-1", "")],
|
||||
)
|
||||
def test_im_endpoint_configuration_rejects_blank_provider_identity(
|
||||
provider_tenant_id: str,
|
||||
provider_user_id: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
IMEndpointConfiguration(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
binding_id=None,
|
||||
provider_user_id=provider_user_id,
|
||||
)
|
||||
|
||||
|
||||
def test_hashes_and_endpoint_capability_owner_are_strict() -> None:
|
||||
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
|
||||
endpoint_ref = grant_ref.endpoint(DeliveryEndpointId("endpoint-1"))
|
||||
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
EndpointAccessCapability(endpoint_ref, "short")
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
EndpointAccessCapability(endpoint_ref, "z" * 64)
|
||||
with pytest.raises(ValueError, match="owner"):
|
||||
DeliveryEndpoint(
|
||||
ref=endpoint_ref,
|
||||
configuration=WebEndpointConfiguration(),
|
||||
address_hash="a" * 64,
|
||||
access_capability=EndpointAccessCapability(grant_ref.endpoint(DeliveryEndpointId("endpoint-2")), "b" * 64),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
UploadCapability(UploadCapabilityId("upload-1"), endpoint_ref, AppId("app-1"), "z" * 64, _NOW, _NOW)
|
||||
|
||||
|
||||
def test_delivery_attempt_validates_sequence_terminal_time_and_failure_diagnostics() -> None:
|
||||
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
|
||||
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
0,
|
||||
HumanInputDeliveryAttemptStatus.QUEUED,
|
||||
_NOW,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="finished_at"):
|
||||
DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.FAILED,
|
||||
_NOW,
|
||||
_NOW,
|
||||
None,
|
||||
None,
|
||||
"failed",
|
||||
None,
|
||||
None,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="only failed"):
|
||||
DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.SENT,
|
||||
_NOW,
|
||||
_NOW,
|
||||
_NOW,
|
||||
"message-1",
|
||||
"invalid",
|
||||
None,
|
||||
None,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="only failed"):
|
||||
DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.SENT,
|
||||
_NOW,
|
||||
_NOW,
|
||||
_NOW,
|
||||
"message-1",
|
||||
None,
|
||||
"invalid",
|
||||
None,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
queued = DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.QUEUED,
|
||||
_NOW,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
assert queued.status is HumanInputDeliveryAttemptStatus.QUEUED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_code", "failure_reason", "provider_response"),
|
||||
[
|
||||
(None, None, None),
|
||||
("", " ", None),
|
||||
],
|
||||
)
|
||||
def test_failed_delivery_attempt_requires_at_least_one_diagnostic(
|
||||
failure_code: str | None,
|
||||
failure_reason: str | None,
|
||||
provider_response: FrozenJSONObject | None,
|
||||
) -> None:
|
||||
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
|
||||
|
||||
with pytest.raises(ValueError, match="failure diagnostic"):
|
||||
DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.FAILED,
|
||||
_NOW,
|
||||
_NOW,
|
||||
_NOW,
|
||||
None,
|
||||
failure_code,
|
||||
failure_reason,
|
||||
provider_response,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_code", "failure_reason", "provider_response"),
|
||||
[
|
||||
("provider_rejected", None, None),
|
||||
(None, "Recipient unavailable", None),
|
||||
(None, None, FrozenJSONObject.from_mapping({"status": 400})),
|
||||
],
|
||||
)
|
||||
def test_failed_delivery_attempt_accepts_each_supported_diagnostic(
|
||||
failure_code: str | None,
|
||||
failure_reason: str | None,
|
||||
provider_response: FrozenJSONObject | None,
|
||||
) -> None:
|
||||
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
|
||||
|
||||
attempt = DeliveryAttempt(
|
||||
DeliveryAttemptId("attempt-1"),
|
||||
endpoint_ref,
|
||||
1,
|
||||
HumanInputDeliveryAttemptStatus.FAILED,
|
||||
_NOW,
|
||||
_NOW,
|
||||
_NOW,
|
||||
None,
|
||||
failure_code,
|
||||
failure_reason,
|
||||
provider_response,
|
||||
_NOW,
|
||||
_NOW,
|
||||
)
|
||||
|
||||
assert attempt.status is HumanInputDeliveryAttemptStatus.FAILED
|
||||
|
||||
|
||||
def test_upload_file_requires_a_non_blank_file_identifier() -> None:
|
||||
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
|
||||
capability = UploadCapability(UploadCapabilityId("upload-1"), endpoint_ref, AppId("app-1"), "b" * 64, _NOW, _NOW)
|
||||
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
UploadFileAssociation(UploadFileAssociationId("association-1"), capability.ref, " ", _NOW, _NOW)
|
||||
|
||||
|
||||
def test_form_and_creation_reject_invalid_owner_and_collection_shapes() -> None:
|
||||
with pytest.raises(TypeError, match="grants"):
|
||||
_form(grants=[_grant()])
|
||||
with pytest.raises(ValueError, match="runtime form"):
|
||||
_form(workflow_pause_id=None)
|
||||
with pytest.raises(ValueError, match="runtime form"):
|
||||
_form(node_execution_id=None)
|
||||
delivery_test = _form(
|
||||
kind=HumanInputV2FormKind.DELIVERY_TEST,
|
||||
workflow_pause_id=None,
|
||||
node_execution_id=None,
|
||||
)
|
||||
assert delivery_test.kind is HumanInputV2FormKind.DELIVERY_TEST
|
||||
|
||||
other_ref = FormRef(WorkspaceId("workspace-2"), FormId("form-2"))
|
||||
with pytest.raises(ValueError, match="another owner"):
|
||||
_form(grants=(_grant(form_ref=other_ref),))
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
_form(grants=(_grant(), _grant()))
|
||||
same_subject = _email_approver()
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
_form(
|
||||
grants=(
|
||||
_grant(grant_id="grant-1", approver=same_subject),
|
||||
_grant(grant_id="grant-2", approver=same_subject),
|
||||
)
|
||||
)
|
||||
|
||||
valid_form = _form()
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
FormCreation(valid_form, [])
|
||||
outside_endpoint = DeliveryEndpoint.from_plan(
|
||||
endpoint_id=DeliveryEndpointId("endpoint-outside"),
|
||||
grant_ref=other_ref.grant(ApproverGrantId("grant-outside")),
|
||||
endpoint_plan=WebEndpointPlan(),
|
||||
access_capability=None,
|
||||
now=_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
FormCreation(valid_form, (outside_endpoint,))
|
||||
|
||||
|
||||
def test_inactive_form_returns_state_instead_of_a_transition_decision() -> None:
|
||||
form = _form(status=HumanInputV2FormStatus.SUBMITTED)
|
||||
|
||||
result = form.decide_submission(
|
||||
grant_id=ApproverGrantId("grant-1"),
|
||||
selected_action_id="approve",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert result.reason is FormInactiveReason.SUBMITTED
|
||||
|
||||
|
||||
def test_failed_recipient_plan_cannot_create_a_form_snapshot() -> None:
|
||||
plan = ResolvedApprovalPlan(
|
||||
approvers=(),
|
||||
rejected_recipients=(),
|
||||
failure_reason=RecipientResolutionFailureReason.NO_VALID_RECIPIENTS,
|
||||
)
|
||||
|
||||
class _UnusedFactory:
|
||||
def new_grant_id(self) -> ApproverGrantId:
|
||||
raise AssertionError("must not generate identifiers")
|
||||
|
||||
def new_endpoint_id(self) -> DeliveryEndpointId:
|
||||
raise AssertionError("must not generate identifiers")
|
||||
|
||||
with pytest.raises(ValueError, match="approvers"):
|
||||
HumanInputForm.create_from_plan(
|
||||
ref=_FORM_REF,
|
||||
app_id=AppId("app-1"),
|
||||
definition=_definition(),
|
||||
rendered_content="Rendered",
|
||||
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
|
||||
kind=HumanInputV2FormKind.RUNTIME,
|
||||
workflow_pause_id="pause-1",
|
||||
node_execution_id="execution-1",
|
||||
plan=plan,
|
||||
identifier_factory=_UnusedFactory(),
|
||||
now=_NOW,
|
||||
)
|
||||
@@ -0,0 +1,519 @@
|
||||
"""Behavior contracts for the independent Email OTP proof-session aggregate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ContactOTPSubject,
|
||||
CurrentEmailOTPIdentity,
|
||||
EmailAddressOTPSubject,
|
||||
FormRef,
|
||||
OTPChallenge,
|
||||
OTPChallengeRejectionReason,
|
||||
OTPCodeHash,
|
||||
VerifiedEmailOTPProof,
|
||||
authorize_email_otp_proof,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputOTPChallengeStatus
|
||||
from core.human_input_v2.shared import (
|
||||
ApproverGrantId,
|
||||
ContactId,
|
||||
FormId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_ISSUED_AT = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_EXPIRES_AT = UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))
|
||||
_RESEND_AFTER = UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60))
|
||||
_RAW_CODE = "123456"
|
||||
_ENCODED_HASH = "test-sha256$8d969eef6ecad3c29a3a629280e686cff8ca4a8d"
|
||||
_TOKEN_HASH = "a" * 64
|
||||
_EMAIL = NormalizedEmail("Reviewer@Example.com")
|
||||
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
|
||||
_GRANT_REF = _FORM_REF.grant(ApproverGrantId("grant-1"))
|
||||
_SUBJECT = ContactOTPSubject(ContactId("contact-1"))
|
||||
|
||||
|
||||
class _MutableClock:
|
||||
current: UtcTimestamp
|
||||
|
||||
def __init__(self, current: UtcTimestamp = _ISSUED_AT) -> None:
|
||||
self.current = current
|
||||
|
||||
def now(self) -> UtcTimestamp:
|
||||
return self.current
|
||||
|
||||
def advance(self, delta: timedelta) -> None:
|
||||
self.current = UtcTimestamp(self.current.value + delta)
|
||||
|
||||
|
||||
class _DeterministicHasher:
|
||||
hash_calls: list[str]
|
||||
verify_calls: list[tuple[str, OTPCodeHash]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.hash_calls = []
|
||||
self.verify_calls = []
|
||||
|
||||
def hash_code(self, plaintext_code: str) -> OTPCodeHash:
|
||||
self.hash_calls.append(plaintext_code)
|
||||
return OTPCodeHash(encoded_value=_ENCODED_HASH, algorithm="test-sha256")
|
||||
|
||||
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool:
|
||||
self.verify_calls.append((plaintext_code, code_hash))
|
||||
return plaintext_code == _RAW_CODE and code_hash.encoded_value == _ENCODED_HASH
|
||||
|
||||
|
||||
def _issue(
|
||||
*,
|
||||
clock: _MutableClock | None = None,
|
||||
hasher: _DeterministicHasher | None = None,
|
||||
challenge_number: int = 1,
|
||||
send_count: int = 1,
|
||||
) -> OTPChallenge:
|
||||
active_clock = clock or _MutableClock()
|
||||
active_hasher = hasher or _DeterministicHasher()
|
||||
return OTPChallenge.issue(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId(f"challenge-{challenge_number}")),
|
||||
subject=_SUBJECT,
|
||||
normalized_email=_EMAIL,
|
||||
challenge_token_hash=_TOKEN_HASH,
|
||||
plaintext_code=_RAW_CODE,
|
||||
send_count=send_count,
|
||||
clock=active_clock,
|
||||
code_hasher=active_hasher,
|
||||
)
|
||||
|
||||
|
||||
def _construct_challenge(
|
||||
*,
|
||||
expires_at: UtcTimestamp = _EXPIRES_AT,
|
||||
resend_after: UtcTimestamp = _RESEND_AFTER,
|
||||
) -> OTPChallenge:
|
||||
return OTPChallenge(
|
||||
ref=_GRANT_REF.challenge(OTPChallengeId("challenge-direct")),
|
||||
subject=_SUBJECT,
|
||||
normalized_email=_EMAIL,
|
||||
challenge_token_hash=_TOKEN_HASH,
|
||||
code_hash=OTPCodeHash(encoded_value=_ENCODED_HASH, algorithm="test-sha256"),
|
||||
status=HumanInputOTPChallengeStatus.PENDING,
|
||||
expires_at=expires_at,
|
||||
resend_after=resend_after,
|
||||
send_count=1,
|
||||
attempt_count=0,
|
||||
verified_at=None,
|
||||
invalidated_at=None,
|
||||
created_at=_ISSUED_AT,
|
||||
updated_at=_ISSUED_AT,
|
||||
)
|
||||
|
||||
|
||||
def test_issue_uses_injected_clock_and_hash_port_without_retaining_plaintext() -> None:
|
||||
clock = _MutableClock()
|
||||
hasher = _DeterministicHasher()
|
||||
|
||||
challenge = _issue(clock=clock, hasher=hasher)
|
||||
|
||||
assert hasher.hash_calls == [_RAW_CODE]
|
||||
assert challenge.created_at == clock.now()
|
||||
assert challenge.expires_at == UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))
|
||||
assert challenge.resend_after == UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60))
|
||||
assert _RAW_CODE not in repr(challenge)
|
||||
assert _RAW_CODE not in challenge.to_public_primitive().values()
|
||||
assert "code_hash" not in challenge.to_public_primitive()
|
||||
assert "challenge_token_hash" not in challenge.to_public_primitive()
|
||||
|
||||
|
||||
def test_challenge_expires_at_the_exact_ten_minute_boundary() -> None:
|
||||
challenge = _issue()
|
||||
|
||||
before = challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=-1)))
|
||||
exact = challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
|
||||
|
||||
assert before.rejection is None
|
||||
assert exact.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
|
||||
|
||||
def test_replacement_rejects_before_cooldown_without_incrementing_send_count() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=59, microseconds=999999)))
|
||||
challenge = _issue()
|
||||
|
||||
decision = challenge.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
|
||||
challenge_token_hash="b" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=_DeterministicHasher(),
|
||||
)
|
||||
|
||||
assert decision.rejection is OTPChallengeRejectionReason.RESEND_COOLDOWN
|
||||
assert decision.previous is challenge
|
||||
assert decision.replacement is None
|
||||
assert decision.previous.send_count == 1
|
||||
|
||||
|
||||
def test_replacement_is_allowed_at_exact_cooldown_and_invalidates_previous() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
|
||||
challenge = _issue()
|
||||
|
||||
decision = challenge.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
|
||||
challenge_token_hash="b" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=_DeterministicHasher(),
|
||||
)
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.previous.status is HumanInputOTPChallengeStatus.INVALIDATED
|
||||
assert decision.previous.invalidated_at == clock.now()
|
||||
assert decision.replacement is not None
|
||||
assert decision.replacement.status is HumanInputOTPChallengeStatus.PENDING
|
||||
assert decision.replacement.send_count == 2
|
||||
assert decision.replacement.attempt_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"elapsed",
|
||||
[timedelta(minutes=10), timedelta(minutes=10, microseconds=1)],
|
||||
ids=["exact-boundary", "past-boundary"],
|
||||
)
|
||||
def test_expired_replacement_returns_expired_previous_without_hashing(elapsed: timedelta) -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + elapsed))
|
||||
hasher = _DeterministicHasher()
|
||||
challenge = _issue()
|
||||
|
||||
decision = challenge.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
|
||||
challenge_token_hash="b" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=hasher,
|
||||
)
|
||||
|
||||
assert decision.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
assert decision.previous.status is HumanInputOTPChallengeStatus.EXPIRED
|
||||
assert decision.previous.updated_at == clock.now()
|
||||
assert decision.previous.send_count == challenge.send_count
|
||||
assert decision.previous.attempt_count == challenge.attempt_count
|
||||
assert decision.replacement is None
|
||||
assert hasher.hash_calls == []
|
||||
|
||||
|
||||
def test_fifth_send_is_allowed_and_further_send_is_rejected_without_hashing() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
|
||||
fourth = _issue(send_count=4)
|
||||
fifth_hasher = _DeterministicHasher()
|
||||
|
||||
fifth = fourth.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-5")),
|
||||
challenge_token_hash="e" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=fifth_hasher,
|
||||
)
|
||||
|
||||
assert fifth.replacement is not None
|
||||
assert fifth.replacement.send_count == 5
|
||||
rejected_hasher = _DeterministicHasher()
|
||||
clock.advance(timedelta(seconds=60))
|
||||
rejected = fifth.replacement.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-6")),
|
||||
challenge_token_hash="f" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=rejected_hasher,
|
||||
)
|
||||
assert rejected.rejection is OTPChallengeRejectionReason.SEND_LIMIT_REACHED
|
||||
assert rejected.previous.send_count == 5
|
||||
assert rejected_hasher.hash_calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("terminal_status", "expected_reason"),
|
||||
[
|
||||
(HumanInputOTPChallengeStatus.VERIFIED, OTPChallengeRejectionReason.ALREADY_VERIFIED),
|
||||
(HumanInputOTPChallengeStatus.INVALIDATED, OTPChallengeRejectionReason.INVALIDATED),
|
||||
(HumanInputOTPChallengeStatus.EXPIRED, OTPChallengeRejectionReason.EXPIRED),
|
||||
],
|
||||
)
|
||||
def test_terminal_challenge_rejects_replacement_without_incrementing_counters(
|
||||
terminal_status: HumanInputOTPChallengeStatus,
|
||||
expected_reason: OTPChallengeRejectionReason,
|
||||
) -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
|
||||
pending = _issue()
|
||||
if terminal_status is HumanInputOTPChallengeStatus.VERIFIED:
|
||||
challenge = pending.verify(
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=_DeterministicHasher(),
|
||||
).challenge
|
||||
elif terminal_status is HumanInputOTPChallengeStatus.INVALIDATED:
|
||||
challenge = pending.invalidate(clock=clock)
|
||||
else:
|
||||
expiry_clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
|
||||
challenge = pending.verify(
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=expiry_clock,
|
||||
code_hasher=_DeterministicHasher(),
|
||||
).challenge
|
||||
previous_attempt_count = challenge.attempt_count
|
||||
hasher = _DeterministicHasher()
|
||||
|
||||
decision = challenge.replace(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
|
||||
challenge_token_hash="b" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=hasher,
|
||||
)
|
||||
|
||||
assert decision.rejection is expected_reason
|
||||
assert decision.previous.send_count == 1
|
||||
assert decision.previous.attempt_count == previous_attempt_count
|
||||
assert hasher.hash_calls == []
|
||||
|
||||
|
||||
def test_five_invalid_attempts_are_consumed_and_sixth_is_rejected_without_hashing() -> None:
|
||||
clock = _MutableClock()
|
||||
hasher = _DeterministicHasher()
|
||||
challenge = _issue(clock=clock, hasher=hasher)
|
||||
|
||||
for expected_count in range(1, 6):
|
||||
decision = challenge.verify(plaintext_code="000000", clock=clock, code_hasher=hasher)
|
||||
assert decision.rejection is OTPChallengeRejectionReason.INVALID_CODE
|
||||
assert decision.challenge.attempt_count == expected_count
|
||||
challenge = decision.challenge
|
||||
|
||||
verify_call_count = len(hasher.verify_calls)
|
||||
rejected = challenge.verify(plaintext_code="000000", clock=clock, code_hasher=hasher)
|
||||
assert rejected.rejection is OTPChallengeRejectionReason.ATTEMPT_LIMIT_REACHED
|
||||
assert rejected.challenge.attempt_count == 5
|
||||
assert len(hasher.verify_calls) == verify_call_count
|
||||
|
||||
|
||||
def test_successful_verification_consumes_one_attempt_and_returns_immutable_limited_proof() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
|
||||
hasher = _DeterministicHasher()
|
||||
challenge = _issue()
|
||||
|
||||
decision = challenge.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.challenge.status is HumanInputOTPChallengeStatus.VERIFIED
|
||||
assert decision.challenge.attempt_count == 1
|
||||
assert decision.proof is not None
|
||||
assert decision.proof == VerifiedEmailOTPProof(
|
||||
challenge_ref=challenge.ref,
|
||||
subject=_SUBJECT,
|
||||
normalized_email=_EMAIL,
|
||||
verified_at=clock.now(),
|
||||
)
|
||||
assert _RAW_CODE not in repr(decision.proof)
|
||||
serialized = decision.proof.to_primitive()
|
||||
assert serialized == {
|
||||
"type": "email_otp",
|
||||
"otp_challenge_id": "challenge-1",
|
||||
"form_id": "form-1",
|
||||
"approver_grant_id": "grant-1",
|
||||
"subject_type": "contact",
|
||||
"contact_id": "contact-1",
|
||||
"verified_email": "reviewer@example.com",
|
||||
"verified_at": "2026-07-25T08:00:30Z",
|
||||
}
|
||||
assert not any("hash" in key or "code" in key for key in serialized)
|
||||
attribute_name = "normalized_email"
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
setattr(decision.proof, attribute_name, NormalizedEmail("other@example.com"))
|
||||
|
||||
|
||||
def test_verified_and_invalidated_challenges_reject_further_verification_without_counter_changes() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
|
||||
hasher = _DeterministicHasher()
|
||||
verified = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher).challenge
|
||||
invalidated = _issue().invalidate(clock=clock)
|
||||
|
||||
verified_result = verified.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
|
||||
invalidated_result = invalidated.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
|
||||
|
||||
assert verified_result.rejection is OTPChallengeRejectionReason.ALREADY_VERIFIED
|
||||
assert invalidated_result.rejection is OTPChallengeRejectionReason.INVALIDATED
|
||||
assert verified_result.challenge.attempt_count == 1
|
||||
assert invalidated_result.challenge.attempt_count == 0
|
||||
|
||||
|
||||
def test_expired_verification_does_not_consume_an_attempt_or_call_hash_port() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
|
||||
hasher = _DeterministicHasher()
|
||||
challenge = _issue()
|
||||
|
||||
decision = challenge.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
|
||||
|
||||
assert decision.rejection is OTPChallengeRejectionReason.EXPIRED
|
||||
assert decision.challenge.status is HumanInputOTPChallengeStatus.EXPIRED
|
||||
assert decision.challenge.attempt_count == 0
|
||||
assert hasher.verify_calls == []
|
||||
|
||||
|
||||
def test_submission_boundary_rejects_raw_code_and_accepts_only_current_verified_identity() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
|
||||
proof = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=_DeterministicHasher()).proof
|
||||
assert proof is not None
|
||||
current_identity = CurrentEmailOTPIdentity(_GRANT_REF, _SUBJECT, _EMAIL)
|
||||
|
||||
raw_result = authorize_email_otp_proof(_RAW_CODE, current_identity=current_identity)
|
||||
accepted = authorize_email_otp_proof(proof, current_identity=current_identity)
|
||||
|
||||
assert raw_result.rejection is OTPChallengeRejectionReason.RAW_CODE_NOT_VERIFIED
|
||||
assert accepted.rejection is None
|
||||
assert accepted.proof is proof
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_identity", "expected_reason"),
|
||||
[
|
||||
(
|
||||
CurrentEmailOTPIdentity(
|
||||
_GRANT_REF,
|
||||
_SUBJECT,
|
||||
NormalizedEmail("changed@example.com"),
|
||||
),
|
||||
OTPChallengeRejectionReason.STALE_IDENTITY,
|
||||
),
|
||||
(CurrentEmailOTPIdentity(_GRANT_REF, None, None), OTPChallengeRejectionReason.STALE_IDENTITY),
|
||||
(
|
||||
CurrentEmailOTPIdentity(
|
||||
_GRANT_REF,
|
||||
ContactOTPSubject(ContactId("recreated-contact")),
|
||||
_EMAIL,
|
||||
),
|
||||
OTPChallengeRejectionReason.STALE_IDENTITY,
|
||||
),
|
||||
(
|
||||
CurrentEmailOTPIdentity(
|
||||
_FORM_REF.grant(ApproverGrantId("other-grant")),
|
||||
_SUBJECT,
|
||||
_EMAIL,
|
||||
),
|
||||
OTPChallengeRejectionReason.GRANT_MISMATCH,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_submission_boundary_rejects_changed_deleted_recreated_or_wrong_grant_identity(
|
||||
current_identity: CurrentEmailOTPIdentity,
|
||||
expected_reason: OTPChallengeRejectionReason,
|
||||
) -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
|
||||
proof = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=_DeterministicHasher()).proof
|
||||
assert proof is not None
|
||||
|
||||
decision = authorize_email_otp_proof(proof, current_identity=current_identity)
|
||||
|
||||
assert decision.rejection is expected_reason
|
||||
|
||||
|
||||
def test_email_address_subject_requires_the_same_normalized_email() -> None:
|
||||
with pytest.raises(ValueError, match="subject email"):
|
||||
OTPChallenge.issue(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-email")),
|
||||
subject=EmailAddressOTPSubject(NormalizedEmail("recipient@example.com")),
|
||||
normalized_email=NormalizedEmail("other@example.com"),
|
||||
challenge_token_hash="c" * 64,
|
||||
plaintext_code=_RAW_CODE,
|
||||
send_count=1,
|
||||
clock=_MutableClock(),
|
||||
code_hasher=_DeterministicHasher(),
|
||||
)
|
||||
|
||||
|
||||
def test_email_address_proof_requires_matching_email_and_serializes_without_contact_identity() -> None:
|
||||
email = NormalizedEmail("recipient@example.com")
|
||||
proof = VerifiedEmailOTPProof(
|
||||
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-email")),
|
||||
subject=EmailAddressOTPSubject(email),
|
||||
normalized_email=email,
|
||||
verified_at=_ISSUED_AT,
|
||||
)
|
||||
|
||||
assert proof.to_primitive()["subject_type"] == "email_address"
|
||||
assert proof.to_primitive()["contact_id"] is None
|
||||
with pytest.raises(ValueError, match="subject email"):
|
||||
VerifiedEmailOTPProof(
|
||||
challenge_ref=proof.challenge_ref,
|
||||
subject=proof.subject,
|
||||
normalized_email=NormalizedEmail("other@example.com"),
|
||||
verified_at=proof.verified_at,
|
||||
)
|
||||
|
||||
|
||||
def test_state_result_exposes_usable_and_rejected_branches() -> None:
|
||||
challenge = _issue()
|
||||
|
||||
assert challenge.state_at(_ISSUED_AT).is_usable is True
|
||||
assert challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))).is_usable is False
|
||||
|
||||
|
||||
def test_challenge_rejects_malformed_persistence_state() -> None:
|
||||
challenge = _issue()
|
||||
|
||||
with pytest.raises(ValueError, match="challenge token hash"):
|
||||
replace(challenge, challenge_token_hash="A" * 64)
|
||||
with pytest.raises(ValueError, match="expires_at"):
|
||||
replace(challenge, expires_at=_ISSUED_AT)
|
||||
with pytest.raises(ValueError, match="updated_at"):
|
||||
replace(challenge, updated_at=UtcTimestamp(_ISSUED_AT.value - timedelta(seconds=1)))
|
||||
with pytest.raises(ValueError, match="requires only verified_at"):
|
||||
replace(challenge, status=HumanInputOTPChallengeStatus.VERIFIED)
|
||||
with pytest.raises(ValueError, match="requires only invalidated_at"):
|
||||
replace(challenge, status=HumanInputOTPChallengeStatus.INVALIDATED)
|
||||
with pytest.raises(ValueError, match="cannot have terminal timestamps"):
|
||||
replace(challenge, verified_at=_ISSUED_AT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resend_after",
|
||||
[
|
||||
UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=59, microseconds=999999)),
|
||||
UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60, microseconds=1)),
|
||||
],
|
||||
ids=["short", "long"],
|
||||
)
|
||||
def test_direct_construction_requires_exact_resend_cooldown(resend_after: UtcTimestamp) -> None:
|
||||
with pytest.raises(ValueError, match="resend_after"):
|
||||
_construct_challenge(resend_after=resend_after)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expires_at",
|
||||
[
|
||||
UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=-1)),
|
||||
UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=1)),
|
||||
],
|
||||
ids=["short", "long"],
|
||||
)
|
||||
def test_direct_construction_requires_exact_expiry(expires_at: UtcTimestamp) -> None:
|
||||
with pytest.raises(ValueError, match="expires_at"):
|
||||
_construct_challenge(expires_at=expires_at)
|
||||
|
||||
|
||||
def test_invalidating_a_terminal_challenge_is_idempotent() -> None:
|
||||
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
|
||||
verified = (
|
||||
_issue()
|
||||
.verify(
|
||||
plaintext_code=_RAW_CODE,
|
||||
clock=clock,
|
||||
code_hasher=_DeterministicHasher(),
|
||||
)
|
||||
.challenge
|
||||
)
|
||||
|
||||
assert verified.invalidate(clock=clock) is verified
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Immutable approval plan value tests."""
|
||||
|
||||
import json
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointPlan,
|
||||
ContactApprovalSubject,
|
||||
DeliveryCapabilitySnapshot,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointPlan,
|
||||
EndUserApprovalSubject,
|
||||
IMEndpointPlan,
|
||||
MatchedRecipientSource,
|
||||
RecipientRejectionReason,
|
||||
RecipientResolutionFailureReason,
|
||||
RecipientSourceKind,
|
||||
RejectedRecipient,
|
||||
ResolvedApprovalPlan,
|
||||
ResolvedApprover,
|
||||
SubjectSnapshot,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
from core.human_input_v2.entities import HumanInputDeliveryChannel, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_subject_keys_are_typed_and_email_keys_do_not_expose_the_address() -> None:
|
||||
email = NormalizedEmail("USER@example.com")
|
||||
|
||||
assert CanonicalSubjectKey.for_contact(ContactId("contact-1")).value == "contact:contact-1"
|
||||
assert CanonicalSubjectKey.for_end_user(EndUserId("end-user-1")).value == "end_user:end-user-1"
|
||||
email_key = CanonicalSubjectKey.for_email(email)
|
||||
assert email_key.value.startswith("email_address:")
|
||||
assert email.value not in email_key.value
|
||||
assert email_key == CanonicalSubjectKey.for_email(NormalizedEmail("user@example.com"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_key",
|
||||
["", "contact:", "end_user:", "email_address:not-a-sha256"],
|
||||
)
|
||||
def test_canonical_subject_key_rejects_values_outside_the_portable_format(invalid_key: str) -> None:
|
||||
with pytest.raises(ValueError, match="canonical subject key"):
|
||||
CanonicalSubjectKey(invalid_key)
|
||||
|
||||
|
||||
def test_subject_values_are_discriminated_without_conflating_delivery_channels() -> None:
|
||||
assert ContactApprovalSubject(ContactId("contact-1")).to_primitive() == {
|
||||
"type": "contact",
|
||||
"contact_id": "contact-1",
|
||||
}
|
||||
assert EndUserApprovalSubject(EndUserId("end-user-1")).to_primitive() == {
|
||||
"type": "end_user",
|
||||
"end_user_id": "end-user-1",
|
||||
}
|
||||
assert EmailAddressApprovalSubject(NormalizedEmail("user@example.com")).to_primitive() == {
|
||||
"type": "email_address",
|
||||
"normalized_email": "user@example.com",
|
||||
}
|
||||
|
||||
|
||||
def test_approval_plan_is_deeply_immutable_and_has_an_explicit_serializable_boundary() -> None:
|
||||
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
|
||||
endpoint = EmailEndpointPlan(NormalizedEmail("user@example.com"))
|
||||
approver = ResolvedApprover(
|
||||
subject=ContactApprovalSubject(ContactId("contact-1")),
|
||||
subject_key=CanonicalSubjectKey.for_contact(ContactId("contact-1")),
|
||||
matched_sources=(source,),
|
||||
subject_snapshot=SubjectSnapshot(display_name="Reviewer", email="user@example.com"),
|
||||
endpoints=(endpoint,),
|
||||
)
|
||||
plan = ResolvedApprovalPlan(approvers=(approver,), rejected_recipients=(), failure_reason=None)
|
||||
|
||||
primitive = plan.to_primitive()
|
||||
|
||||
assert json.loads(json.dumps(primitive)) == primitive
|
||||
assert primitive["approvers"][0]["endpoints"] == [
|
||||
{"channel": HumanInputDeliveryChannel.EMAIL.value, "email_address": "user@example.com"}
|
||||
]
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
plan.approvers = ()
|
||||
with pytest.raises(TypeError):
|
||||
json.dumps(plan)
|
||||
|
||||
|
||||
def test_im_endpoint_and_rejected_fact_keep_transport_neutral_structured_facts() -> None:
|
||||
endpoint = IMEndpointPlan(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
provider_user_id="provider-user-1",
|
||||
)
|
||||
rejection = RejectedRecipient(
|
||||
source=MatchedRecipientSource(RecipientSourceKind.DYNAMIC_EMAIL, 2, "node-1.email"),
|
||||
reason=RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE,
|
||||
rejected_value="list",
|
||||
)
|
||||
plan = ResolvedApprovalPlan(
|
||||
approvers=(),
|
||||
rejected_recipients=(rejection,),
|
||||
failure_reason=RecipientResolutionFailureReason.NO_VALID_RECIPIENTS,
|
||||
)
|
||||
|
||||
assert endpoint.to_primitive() == {
|
||||
"channel": "im",
|
||||
"integration_id": "integration-1",
|
||||
"provider": "feishu",
|
||||
"provider_tenant_id": "provider-tenant-1",
|
||||
"identity_id": "identity-1",
|
||||
"binding_id": "binding-1",
|
||||
"provider_user_id": "provider-user-1",
|
||||
}
|
||||
assert plan.to_primitive()["rejected_recipients"] == [
|
||||
{
|
||||
"source": {"kind": "dynamic_email", "position": 2, "reference": "node-1.email"},
|
||||
"reason": "unsupported_dynamic_type",
|
||||
"rejected_value": "list",
|
||||
}
|
||||
]
|
||||
assert plan.to_primitive()["failure_reason"] == "no_valid_recipients"
|
||||
|
||||
|
||||
def test_value_invariants_reject_mutable_collections_and_inconsistent_subject_keys() -> None:
|
||||
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
|
||||
endpoint = EmailEndpointPlan(NormalizedEmail("user@example.com"))
|
||||
snapshot = SubjectSnapshot("Reviewer", "user@example.com")
|
||||
|
||||
with pytest.raises(ValueError, match="position"):
|
||||
MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, -1, "contact-1")
|
||||
with pytest.raises(TypeError, match="immutable tuples"):
|
||||
ResolvedApprover(
|
||||
ContactApprovalSubject(ContactId("contact-1")),
|
||||
CanonicalSubjectKey.for_contact(ContactId("contact-1")),
|
||||
[source],
|
||||
snapshot,
|
||||
(endpoint,),
|
||||
)
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
ResolvedApprover(
|
||||
ContactApprovalSubject(ContactId("contact-1")),
|
||||
CanonicalSubjectKey.for_contact(ContactId("contact-2")),
|
||||
(source,),
|
||||
snapshot,
|
||||
(endpoint,),
|
||||
)
|
||||
|
||||
|
||||
def test_plan_and_capability_invariants_require_deeply_immutable_consistent_values() -> None:
|
||||
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
|
||||
approver = ResolvedApprover(
|
||||
ContactApprovalSubject(ContactId("contact-1")),
|
||||
CanonicalSubjectKey.for_contact(ContactId("contact-1")),
|
||||
(source,),
|
||||
SubjectSnapshot("Reviewer", "user@example.com"),
|
||||
(EmailEndpointPlan(NormalizedEmail("user@example.com")),),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="immutable tuples"):
|
||||
ResolvedApprovalPlan([approver], (), None)
|
||||
with pytest.raises(ValueError, match="cannot have a failure"):
|
||||
ResolvedApprovalPlan((approver,), (), RecipientResolutionFailureReason.NO_VALID_RECIPIENTS)
|
||||
with pytest.raises(ValueError, match="must have a failure"):
|
||||
ResolvedApprovalPlan((), (), None)
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
DeliveryCapabilitySnapshot(im_bindings=[])
|
||||
with pytest.raises(TypeError, match="immutable frozensets"):
|
||||
DeliveryCapabilitySnapshot(contact_web_ids=set())
|
||||
|
||||
|
||||
def test_interaction_endpoints_and_unbound_im_endpoint_have_stable_primitive_shapes() -> None:
|
||||
endpoint = IMEndpointPlan(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
binding_id=None,
|
||||
provider_user_id="provider-user-1",
|
||||
)
|
||||
|
||||
assert endpoint.to_primitive()["binding_id"] is None
|
||||
assert WebEndpointPlan().to_primitive() == {"channel": "web"}
|
||||
assert ConsoleEndpointPlan().to_primitive() == {"channel": "console"}
|
||||
@@ -0,0 +1,557 @@
|
||||
"""Behavior tests for the single-entry recipient resolver."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
CanonicalSubjectKey,
|
||||
ConsoleEndpointPlan,
|
||||
ContactApprovalSubject,
|
||||
ContactInitiatorSnapshot,
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DebugRecipientReplacement,
|
||||
DeliveryCapabilitySnapshot,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailEndpointPlan,
|
||||
EndUserApprovalSubject,
|
||||
EndUserInitiatorSnapshot,
|
||||
IMEndpointPlan,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
RecipientRejectionReason,
|
||||
RecipientResolutionFailureReason,
|
||||
RecipientResolver,
|
||||
RecipientSourceKind,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
WebEndpointPlan,
|
||||
)
|
||||
from core.human_input_v2.contact_directory import Contact, ContactDirectorySnapshot
|
||||
from core.human_input_v2.entities import HumanInputDeliveryChannel, IMProvider
|
||||
from core.human_input_v2.im_integration import EffectiveIMBindingSnapshot
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
EndUserId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_WORKSPACE_ID = WorkspaceId("workspace-1")
|
||||
|
||||
|
||||
def _workspace_contact(
|
||||
contact_id: str = "contact-1",
|
||||
account_id: str = "account-1",
|
||||
email: str | None = "reviewer@example.com",
|
||||
workspace_id: WorkspaceId = _WORKSPACE_ID,
|
||||
) -> Contact:
|
||||
return Contact.workspace_member(
|
||||
contact_id=ContactId(contact_id),
|
||||
workspace_id=workspace_id,
|
||||
account_id=AccountId(account_id),
|
||||
name=f"Reviewer {contact_id}",
|
||||
email=email,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _organization_contact(
|
||||
contact_id: str = "contact-1",
|
||||
account_id: str = "account-1",
|
||||
email: str | None = "reviewer@example.com",
|
||||
) -> Contact:
|
||||
return Contact.organization_account(
|
||||
contact_id=ContactId(contact_id),
|
||||
account_id=AccountId(account_id),
|
||||
name=f"Reviewer {contact_id}",
|
||||
email=email,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _directory(*contacts: Contact) -> ContactDirectorySnapshot:
|
||||
selected_contacts = contacts or (_workspace_contact(),)
|
||||
return ContactDirectorySnapshot(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contacts=selected_contacts,
|
||||
member_account_ids=frozenset(
|
||||
contact.account_id for contact in selected_contacts if contact.account_id is not None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _im_binding(
|
||||
*,
|
||||
contact_id: str = "contact-1",
|
||||
integration_id: str = "integration-1",
|
||||
identity_id: str = "identity-1",
|
||||
binding_id: str | None = "binding-1",
|
||||
provider: IMProvider = IMProvider.FEISHU,
|
||||
) -> EffectiveIMBindingSnapshot:
|
||||
return EffectiveIMBindingSnapshot(
|
||||
integration_id=IntegrationId(integration_id),
|
||||
integration_config_version=1,
|
||||
provider=provider,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
contact_id=ContactId(contact_id),
|
||||
account_id=AccountId("account-1"),
|
||||
identity_id=IMIdentityId(identity_id),
|
||||
binding_id=IMBindingId(binding_id) if binding_id is not None else None,
|
||||
provider_user_id=f"provider-{identity_id}",
|
||||
display_name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
)
|
||||
|
||||
|
||||
def test_static_contact_dynamic_email_and_initiator_collapse_to_one_contact() -> None:
|
||||
specifications = (
|
||||
ContactRecipientSpecification("contact-1"),
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
CurrentInitiatorRecipientSpecification(),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=(DynamicRecipientValue(("node-1", "email"), " REVIEWER@example.com "),),
|
||||
initiator=ContactInitiatorSnapshot(ContactId("contact-1")),
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert len(plan.approvers) == 1
|
||||
approver = plan.approvers[0]
|
||||
assert approver.subject == ContactApprovalSubject(ContactId("contact-1"))
|
||||
assert [source.kind for source in approver.matched_sources] == [
|
||||
RecipientSourceKind.STATIC_CONTACT,
|
||||
RecipientSourceKind.DYNAMIC_EMAIL,
|
||||
RecipientSourceKind.CURRENT_INITIATOR,
|
||||
]
|
||||
assert approver.endpoints == (EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),)
|
||||
assert plan.rejected_recipients == ()
|
||||
|
||||
|
||||
def test_unmatched_email_is_upgraded_to_email_subject_and_normalized_duplicates_collapse() -> None:
|
||||
specifications = (
|
||||
OneTimeEmailRecipientSpecification(" Person@Example.com "),
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=(DynamicRecipientValue(("node-1", "email"), "person@example.COM"),),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert len(plan.approvers) == 1
|
||||
approver = plan.approvers[0]
|
||||
assert approver.subject == EmailAddressApprovalSubject(NormalizedEmail("person@example.com"))
|
||||
assert approver.subject_key == CanonicalSubjectKey.for_email(NormalizedEmail("person@example.com"))
|
||||
assert [source.kind for source in approver.matched_sources] == [
|
||||
RecipientSourceKind.ONE_TIME_EMAIL,
|
||||
RecipientSourceKind.DYNAMIC_EMAIL,
|
||||
]
|
||||
assert approver.endpoints == (EmailEndpointPlan(NormalizedEmail("person@example.com")),)
|
||||
|
||||
|
||||
def test_email_matching_contact_with_unavailable_account_does_not_bypass_contact_governance() -> None:
|
||||
contact = _workspace_contact()
|
||||
directory = ContactDirectorySnapshot(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contacts=(contact,),
|
||||
member_account_ids=frozenset({AccountId("account-1")}),
|
||||
unavailable_account_ids=frozenset({AccountId("account-1")}),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
|
||||
directory=directory,
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert plan.approvers == ()
|
||||
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
|
||||
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
|
||||
]
|
||||
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
|
||||
|
||||
def test_email_matching_organization_contact_outside_workspace_visibility_does_not_fallback() -> None:
|
||||
contact = _organization_contact()
|
||||
directory = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(contact,))
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
|
||||
directory=directory,
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert plan.approvers == ()
|
||||
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
|
||||
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
|
||||
]
|
||||
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
|
||||
|
||||
def test_email_matching_cross_workspace_contact_does_not_fallback() -> None:
|
||||
contact = _workspace_contact(workspace_id=WorkspaceId("workspace-2"))
|
||||
directory = ContactDirectorySnapshot(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contacts=(contact,),
|
||||
member_account_ids=frozenset({AccountId("account-1")}),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
|
||||
directory=directory,
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert plan.approvers == ()
|
||||
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
|
||||
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
|
||||
]
|
||||
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
|
||||
|
||||
def test_invalid_and_unsupported_recipients_are_retained_without_dropping_valid_approvers() -> None:
|
||||
specifications = (
|
||||
OneTimeEmailRecipientSpecification("not-an-email"),
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
OneTimeEmailRecipientSpecification("valid@example.com"),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=(
|
||||
DynamicRecipientValue(
|
||||
("node-1", "email"),
|
||||
UnsupportedDynamicRecipientValue("list"),
|
||||
),
|
||||
),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert [approver.subject for approver in plan.approvers] == [
|
||||
EmailAddressApprovalSubject(NormalizedEmail("valid@example.com"))
|
||||
]
|
||||
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
|
||||
(RecipientRejectionReason.INVALID_EMAIL, "not-an-email"),
|
||||
(RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE, "list"),
|
||||
]
|
||||
assert plan.failure_reason is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("specification", "dynamic_values", "reason", "value"),
|
||||
[
|
||||
(ContactRecipientSpecification(" "), (), RecipientRejectionReason.INVALID_CONTACT_ID, " "),
|
||||
(
|
||||
DynamicEmailRecipientSpecification(()),
|
||||
(),
|
||||
RecipientRejectionReason.INVALID_DYNAMIC_SELECTOR,
|
||||
"",
|
||||
),
|
||||
(
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
(),
|
||||
RecipientRejectionReason.DYNAMIC_VALUE_UNAVAILABLE,
|
||||
"node-1.email",
|
||||
),
|
||||
(
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
(DynamicRecipientValue(("node-1", "email"), "invalid"),),
|
||||
RecipientRejectionReason.INVALID_EMAIL,
|
||||
"invalid",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_input_paths_return_stable_no_valid_recipients(
|
||||
specification,
|
||||
dynamic_values: tuple[DynamicRecipientValue, ...],
|
||||
reason: RecipientRejectionReason,
|
||||
value: str,
|
||||
) -> None:
|
||||
first = RecipientResolver.resolve(
|
||||
specifications=(specification,),
|
||||
directory=_directory(),
|
||||
dynamic_values=dynamic_values,
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
second = RecipientResolver.resolve(
|
||||
specifications=(specification,),
|
||||
directory=_directory(),
|
||||
dynamic_values=dynamic_values,
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert first.approvers == ()
|
||||
assert [(rejection.reason, rejection.rejected_value) for rejection in first.rejected_recipients] == [
|
||||
(reason, value)
|
||||
]
|
||||
assert first.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
|
||||
|
||||
def test_unavailable_contact_and_initiator_do_not_block_other_recipients() -> None:
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(
|
||||
ContactRecipientSpecification("missing-contact"),
|
||||
CurrentInitiatorRecipientSpecification(),
|
||||
OneTimeEmailRecipientSpecification("valid@example.com"),
|
||||
),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert len(plan.approvers) == 1
|
||||
assert [rejection.reason for rejection in plan.rejected_recipients] == [
|
||||
RecipientRejectionReason.CONTACT_UNAVAILABLE,
|
||||
RecipientRejectionReason.INITIATOR_UNAVAILABLE,
|
||||
]
|
||||
|
||||
|
||||
def test_email_and_all_effective_im_bindings_create_parallel_deterministic_endpoints() -> None:
|
||||
capabilities = DeliveryCapabilitySnapshot(
|
||||
im_bindings=(
|
||||
_im_binding(
|
||||
integration_id="integration-2",
|
||||
identity_id="identity-2",
|
||||
provider=IMProvider.SLACK,
|
||||
),
|
||||
_im_binding(),
|
||||
),
|
||||
contact_console_ids=frozenset({ContactId("contact-1")}),
|
||||
)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(ContactRecipientSpecification("contact-1"),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
assert [endpoint.channel for endpoint in plan.approvers[0].endpoints] == [
|
||||
HumanInputDeliveryChannel.EMAIL,
|
||||
HumanInputDeliveryChannel.IM,
|
||||
HumanInputDeliveryChannel.IM,
|
||||
HumanInputDeliveryChannel.CONSOLE,
|
||||
]
|
||||
assert plan.approvers[0].endpoints == (
|
||||
EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
|
||||
IMEndpointPlan(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
provider_user_id="provider-identity-1",
|
||||
),
|
||||
IMEndpointPlan(
|
||||
integration_id=IntegrationId("integration-2"),
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=IMIdentityId("identity-2"),
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
provider_user_id="provider-identity-2",
|
||||
),
|
||||
ConsoleEndpointPlan(),
|
||||
)
|
||||
|
||||
|
||||
def test_email_only_and_explicit_web_capability_are_planned_separately() -> None:
|
||||
email_only = RecipientResolver.resolve(
|
||||
specifications=(OneTimeEmailRecipientSpecification("person@example.com"),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
email_and_web = RecipientResolver.resolve(
|
||||
specifications=(OneTimeEmailRecipientSpecification("person@example.com"),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(email_address_web_available=True),
|
||||
)
|
||||
|
||||
assert email_only.approvers[0].endpoints == (EmailEndpointPlan(NormalizedEmail("person@example.com")),)
|
||||
assert email_and_web.approvers[0].endpoints == (
|
||||
EmailEndpointPlan(NormalizedEmail("person@example.com")),
|
||||
WebEndpointPlan(),
|
||||
)
|
||||
|
||||
|
||||
def test_subject_without_any_usable_endpoint_is_rejected() -> None:
|
||||
contact = _workspace_contact(email=None)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(ContactRecipientSpecification("contact-1"),),
|
||||
directory=_directory(contact),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
assert plan.approvers == ()
|
||||
assert [rejection.reason for rejection in plan.rejected_recipients] == [RecipientRejectionReason.NO_USABLE_ENDPOINT]
|
||||
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
|
||||
|
||||
|
||||
def test_debug_replacement_is_request_scoped_and_does_not_mutate_saved_specifications() -> None:
|
||||
saved_specifications = (OneTimeEmailRecipientSpecification("configured@example.com"),)
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=saved_specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
debug_replacement=DebugRecipientReplacement(ContactInitiatorSnapshot(ContactId("contact-1"))),
|
||||
)
|
||||
|
||||
assert saved_specifications == (OneTimeEmailRecipientSpecification("configured@example.com"),)
|
||||
assert [approver.subject for approver in plan.approvers] == [ContactApprovalSubject(ContactId("contact-1"))]
|
||||
assert [source.kind for source in plan.approvers[0].matched_sources] == [RecipientSourceKind.DEBUG_REPLACEMENT]
|
||||
|
||||
|
||||
def test_end_user_initiator_remains_a_distinct_subject_with_request_scoped_capabilities() -> None:
|
||||
end_user_id = EndUserId("end-user-1")
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(CurrentInitiatorRecipientSpecification(),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=EndUserInitiatorSnapshot(
|
||||
end_user_id=end_user_id,
|
||||
display_name="End User",
|
||||
email="ENDUSER@example.com",
|
||||
),
|
||||
capabilities=DeliveryCapabilitySnapshot(end_user_web_ids=frozenset({end_user_id})),
|
||||
)
|
||||
|
||||
assert plan.approvers[0].subject == EndUserApprovalSubject(end_user_id)
|
||||
assert plan.approvers[0].endpoints == (
|
||||
EmailEndpointPlan(NormalizedEmail("enduser@example.com")),
|
||||
WebEndpointPlan(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", [None, "invalid"])
|
||||
def test_end_user_initiator_without_usable_email_can_use_an_explicit_web_endpoint(email: str | None) -> None:
|
||||
end_user_id = EndUserId("end-user-1")
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(CurrentInitiatorRecipientSpecification(),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=EndUserInitiatorSnapshot(end_user_id, "End User", email),
|
||||
capabilities=DeliveryCapabilitySnapshot(end_user_web_ids=frozenset({end_user_id})),
|
||||
)
|
||||
|
||||
assert plan.approvers[0].endpoints == (WebEndpointPlan(),)
|
||||
|
||||
|
||||
def test_contact_web_capability_and_duplicate_endpoints_are_deterministically_deduplicated() -> None:
|
||||
duplicate_binding = _im_binding()
|
||||
|
||||
plan = RecipientResolver.resolve(
|
||||
specifications=(
|
||||
ContactRecipientSpecification("contact-1"),
|
||||
ContactRecipientSpecification("contact-1"),
|
||||
),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(
|
||||
im_bindings=(duplicate_binding, duplicate_binding),
|
||||
contact_web_ids=frozenset({ContactId("contact-1")}),
|
||||
),
|
||||
)
|
||||
|
||||
assert [endpoint.channel for endpoint in plan.approvers[0].endpoints] == [
|
||||
HumanInputDeliveryChannel.EMAIL,
|
||||
HumanInputDeliveryChannel.IM,
|
||||
HumanInputDeliveryChannel.WEB,
|
||||
]
|
||||
|
||||
|
||||
def test_resolver_rejects_mutable_top_level_inputs() -> None:
|
||||
with pytest.raises(TypeError, match="immutable tuples"):
|
||||
RecipientResolver.resolve(
|
||||
specifications=[OneTimeEmailRecipientSpecification("user@example.com")],
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=None,
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_recipient_specification_fails_closed_instead_of_resolving_current_initiator() -> None:
|
||||
with pytest.raises(AssertionError):
|
||||
RecipientResolver.resolve(
|
||||
specifications=(object(),),
|
||||
directory=_directory(),
|
||||
dynamic_values=(),
|
||||
initiator=ContactInitiatorSnapshot(ContactId("contact-1")),
|
||||
capabilities=DeliveryCapabilitySnapshot(),
|
||||
)
|
||||
|
||||
|
||||
def test_every_ordered_plan_component_is_equal_across_repeated_resolution() -> None:
|
||||
specifications = (
|
||||
OneTimeEmailRecipientSpecification("z@example.com"),
|
||||
ContactRecipientSpecification("contact-1"),
|
||||
OneTimeEmailRecipientSpecification("a@example.com"),
|
||||
DynamicEmailRecipientSpecification(("node-1", "bad")),
|
||||
)
|
||||
dynamic_values = (DynamicRecipientValue(("node-1", "bad"), "invalid"),)
|
||||
capabilities = DeliveryCapabilitySnapshot(
|
||||
im_bindings=(_im_binding(),),
|
||||
contact_console_ids=frozenset({ContactId("contact-1")}),
|
||||
)
|
||||
|
||||
first = RecipientResolver.resolve(
|
||||
specifications=specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=dynamic_values,
|
||||
initiator=None,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
second = RecipientResolver.resolve(
|
||||
specifications=specifications,
|
||||
directory=_directory(),
|
||||
dynamic_values=dynamic_values,
|
||||
initiator=None,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
|
||||
assert first == second
|
||||
assert [approver.subject_key.value for approver in first.approvers] == [
|
||||
CanonicalSubjectKey.for_email(NormalizedEmail("z@example.com")).value,
|
||||
"contact:contact-1",
|
||||
CanonicalSubjectKey.for_email(NormalizedEmail("a@example.com")).value,
|
||||
]
|
||||
assert [rejection.source.position for rejection in first.rejected_recipients] == [3]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Recipient specification and workflow configuration boundary tests."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
ContactRecipientSpecification,
|
||||
CurrentInitiatorRecipientSpecification,
|
||||
DynamicEmailRecipientSpecification,
|
||||
DynamicRecipientValue,
|
||||
OneTimeEmailRecipientSpecification,
|
||||
UnsupportedDynamicRecipientValue,
|
||||
WorkflowRecipientSpecificationAdapter,
|
||||
)
|
||||
from core.workflow.nodes.human_input_v2.entities import (
|
||||
Contact,
|
||||
DebugModeConfig,
|
||||
DynamicEmail,
|
||||
HumanInputNodeData,
|
||||
Initiator,
|
||||
MessageTemplateConfig,
|
||||
OnetimeEmail,
|
||||
)
|
||||
|
||||
|
||||
def _node_data() -> HumanInputNodeData:
|
||||
return HumanInputNodeData(
|
||||
title="Approval",
|
||||
recipients_spec=[
|
||||
Contact(contact_id="contact-1"),
|
||||
OnetimeEmail(email=" INVALID "),
|
||||
DynamicEmail(selector=["node-1", "email"]),
|
||||
Initiator(),
|
||||
],
|
||||
message_template=MessageTemplateConfig(subject="Approval requested", body="Review the request"),
|
||||
debug_mode=DebugModeConfig(channels=[]),
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_node_configuration_is_explicitly_adapted_to_immutable_specifications() -> None:
|
||||
node_data = _node_data()
|
||||
|
||||
specifications = WorkflowRecipientSpecificationAdapter.from_node_data(node_data)
|
||||
|
||||
assert specifications == (
|
||||
ContactRecipientSpecification(contact_id="contact-1"),
|
||||
OneTimeEmailRecipientSpecification(email=" INVALID "),
|
||||
DynamicEmailRecipientSpecification(selector=("node-1", "email")),
|
||||
CurrentInitiatorRecipientSpecification(),
|
||||
)
|
||||
node_data.recipients_spec.append(OnetimeEmail(email="later@example.com"))
|
||||
assert len(specifications) == 4
|
||||
|
||||
|
||||
def test_specification_values_are_frozen_and_serialize_only_at_the_boundary() -> None:
|
||||
specification = OneTimeEmailRecipientSpecification(email=" USER@example.com ")
|
||||
|
||||
assert specification.to_primitive() == {"type": "onetime_email", "email": " USER@example.com "}
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
specification.email = "other@example.com"
|
||||
|
||||
|
||||
def test_dynamic_runtime_values_capture_supported_and_unsupported_types_immutably() -> None:
|
||||
supported = DynamicRecipientValue.from_runtime(("node-1", "email"), "user@example.com")
|
||||
unsupported = DynamicRecipientValue.from_runtime(("node-2", "emails"), ["user@example.com"])
|
||||
|
||||
assert supported == DynamicRecipientValue(selector=("node-1", "email"), value="user@example.com")
|
||||
assert unsupported == DynamicRecipientValue(
|
||||
selector=("node-2", "emails"),
|
||||
value=UnsupportedDynamicRecipientValue(value_type="list"),
|
||||
)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
unsupported.selector = ("other",)
|
||||
|
||||
|
||||
def test_specification_and_runtime_snapshots_reject_mutable_or_invalid_shapes() -> None:
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
DynamicEmailRecipientSpecification(["node-1", "email"])
|
||||
with pytest.raises(TypeError, match="immutable tuple"):
|
||||
DynamicRecipientValue(["node-1", "email"], "user@example.com")
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
UnsupportedDynamicRecipientValue("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("specification", "primitive"),
|
||||
[
|
||||
(ContactRecipientSpecification("contact-1"), {"type": "contact", "contact_id": "contact-1"}),
|
||||
(
|
||||
DynamicEmailRecipientSpecification(("node-1", "email")),
|
||||
{"type": "dynamic_email", "selector": ["node-1", "email"]},
|
||||
),
|
||||
(CurrentInitiatorRecipientSpecification(), {"type": "initiator"}),
|
||||
],
|
||||
)
|
||||
def test_every_specification_has_a_stable_primitive_shape(specification, primitive: dict[str, object]) -> None:
|
||||
assert specification.to_primitive() == primitive
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Pure current-state authorization contracts for Human Input v2 submission."""
|
||||
|
||||
from dataclasses import fields
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.approval import (
|
||||
AccountSubmissionActor,
|
||||
ApproverGrant,
|
||||
AuthorizationContext,
|
||||
ContactApprovalSubject,
|
||||
ContactOTPSubject,
|
||||
CurrentContactAuthorizationFacts,
|
||||
CurrentEndUserAuthorizationFacts,
|
||||
CurrentIMAuthorizationFacts,
|
||||
DeliveryEndpoint,
|
||||
EmailAddressApprovalSubject,
|
||||
EmailAddressOTPSubject,
|
||||
EmailAddressSubmissionActor,
|
||||
EndUserApprovalSubject,
|
||||
EndUserSubmissionActor,
|
||||
FormRef,
|
||||
FrozenFormAction,
|
||||
FrozenFormDefinition,
|
||||
FrozenJSONObject,
|
||||
HumanInputForm,
|
||||
IMEndpointConfiguration,
|
||||
SubmissionAuthorizationDecision,
|
||||
SubmissionAuthorizationRejection,
|
||||
SubmissionAuthorizer,
|
||||
VerifiedAccountSessionProof,
|
||||
VerifiedEmailOTPProof,
|
||||
VerifiedIMIdentityProof,
|
||||
VerifiedTrustedEndUserProof,
|
||||
)
|
||||
from core.human_input_v2.approval.recipient_resolution import CanonicalSubjectKey, SubjectSnapshot
|
||||
from core.human_input_v2.entities import HumanInputV2FormKind, HumanInputV2FormStatus, IMProvider
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
AppId,
|
||||
ApproverGrantId,
|
||||
ContactId,
|
||||
DeliveryEndpointId,
|
||||
EndUserId,
|
||||
FormId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
NormalizedEmail,
|
||||
OTPChallengeId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
|
||||
_ACCOUNT_ID = AccountId("account-1")
|
||||
_CONTACT_ID = ContactId("contact-1")
|
||||
_EMAIL = NormalizedEmail("reviewer@example.com")
|
||||
_END_USER_ID = EndUserId("end-user-1")
|
||||
_INTEGRATION_ID = IntegrationId("integration-1")
|
||||
_IM_IDENTITY_ID = IMIdentityId("identity-1")
|
||||
_IM_BINDING_ID = IMBindingId("binding-1")
|
||||
|
||||
|
||||
def _grant(subject: ContactApprovalSubject | EndUserApprovalSubject | EmailAddressApprovalSubject) -> ApproverGrant:
|
||||
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
|
||||
if isinstance(subject, ContactApprovalSubject):
|
||||
subject_key = CanonicalSubjectKey.for_contact(subject.contact_id)
|
||||
elif isinstance(subject, EndUserApprovalSubject):
|
||||
subject_key = CanonicalSubjectKey.for_end_user(subject.end_user_id)
|
||||
else:
|
||||
subject_key = CanonicalSubjectKey.for_email(subject.normalized_email)
|
||||
return ApproverGrant(
|
||||
ref=grant_ref,
|
||||
subject=subject,
|
||||
subject_key=subject_key,
|
||||
matched_sources=(),
|
||||
subject_snapshot=SubjectSnapshot("Reviewer", str(_EMAIL)),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _form(grant: ApproverGrant, *, status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING) -> HumanInputForm:
|
||||
return HumanInputForm(
|
||||
ref=_FORM_REF,
|
||||
app_id=AppId("app-1"),
|
||||
definition=FrozenFormDefinition(
|
||||
form_content="Approve",
|
||||
inputs=(),
|
||||
actions=(FrozenFormAction("approve", "Approve", "primary"),),
|
||||
default_values=FrozenJSONObject.from_mapping({}),
|
||||
node_title="Review",
|
||||
display_in_ui=True,
|
||||
),
|
||||
rendered_content="Approve",
|
||||
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
|
||||
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
|
||||
kind=HumanInputV2FormKind.RUNTIME,
|
||||
status=status,
|
||||
workflow_pause_id="pause-1",
|
||||
node_execution_id="node-execution-1",
|
||||
grants=(grant,),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _im_endpoint(grant: ApproverGrant) -> DeliveryEndpoint:
|
||||
return DeliveryEndpoint(
|
||||
ref=grant.ref.endpoint(DeliveryEndpointId("endpoint-1")),
|
||||
configuration=IMEndpointConfiguration(
|
||||
integration_id=_INTEGRATION_ID,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
identity_id=_IM_IDENTITY_ID,
|
||||
binding_id=_IM_BINDING_ID,
|
||||
provider_user_id="provider-user-1",
|
||||
),
|
||||
address_hash="a" * 64,
|
||||
access_capability=None,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _contact_facts(
|
||||
*,
|
||||
contact_id: ContactId = _CONTACT_ID,
|
||||
account_id: AccountId | None = _ACCOUNT_ID,
|
||||
normalized_email: NormalizedEmail | None = _EMAIL,
|
||||
account_active: bool = True,
|
||||
workspace_available: bool = True,
|
||||
) -> CurrentContactAuthorizationFacts:
|
||||
return CurrentContactAuthorizationFacts(
|
||||
contact_id=contact_id,
|
||||
account_id=account_id,
|
||||
normalized_email=normalized_email,
|
||||
account_active=account_active,
|
||||
workspace_available=workspace_available,
|
||||
)
|
||||
|
||||
|
||||
def _im_facts(*, binding_id: IMBindingId | None = _IM_BINDING_ID) -> CurrentIMAuthorizationFacts:
|
||||
return CurrentIMAuthorizationFacts(
|
||||
integration_id=_INTEGRATION_ID,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
contact_id=_CONTACT_ID,
|
||||
account_id=_ACCOUNT_ID,
|
||||
identity_id=_IM_IDENTITY_ID,
|
||||
binding_id=binding_id,
|
||||
provider_user_id="provider-user-1",
|
||||
)
|
||||
|
||||
|
||||
def _context(
|
||||
grant: ApproverGrant,
|
||||
*,
|
||||
endpoint: DeliveryEndpoint | None = None,
|
||||
contact: CurrentContactAuthorizationFacts | None = None,
|
||||
end_user: CurrentEndUserAuthorizationFacts | None = None,
|
||||
im: CurrentIMAuthorizationFacts | None = None,
|
||||
status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING,
|
||||
) -> AuthorizationContext:
|
||||
return AuthorizationContext(
|
||||
form=_form(grant, status=status),
|
||||
grant=grant,
|
||||
endpoint=endpoint,
|
||||
current_contact=contact,
|
||||
current_end_user=end_user,
|
||||
current_im_binding=im,
|
||||
)
|
||||
|
||||
|
||||
def _email_proof(grant: ApproverGrant, *, contact_id: ContactId | None = _CONTACT_ID) -> VerifiedEmailOTPProof:
|
||||
subject = ContactOTPSubject(contact_id) if contact_id is not None else EmailAddressOTPSubject(_EMAIL)
|
||||
return VerifiedEmailOTPProof(
|
||||
challenge_ref=grant.ref.challenge(OTPChallengeId("challenge-1")),
|
||||
subject=subject,
|
||||
normalized_email=_EMAIL,
|
||||
verified_at=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _im_proof(*, binding_id: IMBindingId | None = _IM_BINDING_ID) -> VerifiedIMIdentityProof:
|
||||
return VerifiedIMIdentityProof(
|
||||
integration_id=_INTEGRATION_ID,
|
||||
identity_id=_IM_IDENTITY_ID,
|
||||
binding_id=binding_id,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id="provider-tenant-1",
|
||||
provider_user_id="provider-user-1",
|
||||
)
|
||||
|
||||
|
||||
def _authorize(
|
||||
context: AuthorizationContext,
|
||||
proof: object,
|
||||
*,
|
||||
selected_action_id: str = "approve",
|
||||
now: UtcTimestamp = _NOW,
|
||||
) -> SubmissionAuthorizationDecision:
|
||||
return SubmissionAuthorizer.authorize(
|
||||
context=context,
|
||||
proof=proof,
|
||||
selected_action_id=selected_action_id,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_candidate", ["123456", {"token": "secret"}, object()])
|
||||
def test_raw_credentials_fail_closed_at_the_pure_authorization_boundary(raw_candidate: object) -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(_context(grant, contact=_contact_facts()), raw_candidate)
|
||||
|
||||
assert decision.authorized is None
|
||||
assert decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
|
||||
|
||||
|
||||
def test_grant_endpoint_verified_proof_and_actor_are_not_interchangeable() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
endpoint = _im_endpoint(grant)
|
||||
context = _context(grant, endpoint=endpoint, contact=_contact_facts(), im=_im_facts())
|
||||
|
||||
grant_decision = _authorize(context, grant)
|
||||
endpoint_decision = _authorize(context, endpoint)
|
||||
proof_decision = _authorize(context, _im_proof())
|
||||
|
||||
assert grant_decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
|
||||
assert endpoint_decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
|
||||
assert isinstance(proof_decision.authorized.actor, AccountSubmissionActor)
|
||||
assert proof_decision.authorized.proof != proof_decision.authorized.actor
|
||||
|
||||
|
||||
def test_verified_account_session_resolves_the_current_contact_account_actor() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(_context(grant, contact=_contact_facts()), VerifiedAccountSessionProof(_ACCOUNT_ID))
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
|
||||
|
||||
|
||||
def test_trusted_end_user_remains_a_distinct_end_user_actor() -> None:
|
||||
grant = _grant(EndUserApprovalSubject(_END_USER_ID))
|
||||
current_end_user = CurrentEndUserAuthorizationFacts(
|
||||
end_user_id=_END_USER_ID,
|
||||
app_id=AppId("app-1"),
|
||||
workspace_available=True,
|
||||
)
|
||||
|
||||
decision = _authorize(
|
||||
_context(grant, end_user=current_end_user),
|
||||
VerifiedTrustedEndUserProof(end_user_id=_END_USER_ID, app_id=AppId("app-1")),
|
||||
)
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == EndUserSubmissionActor(_END_USER_ID)
|
||||
|
||||
|
||||
def test_standalone_email_otp_resolves_an_email_address_actor() -> None:
|
||||
grant = _grant(EmailAddressApprovalSubject(_EMAIL))
|
||||
|
||||
decision = _authorize(_context(grant), _email_proof(grant, contact_id=None))
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == EmailAddressSubmissionActor(_EMAIL)
|
||||
|
||||
|
||||
def test_contact_email_otp_uses_current_account_when_available() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(_context(grant, contact=_contact_facts()), _email_proof(grant))
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
|
||||
|
||||
|
||||
def test_external_contact_email_otp_uses_the_verified_email_actor() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(
|
||||
_context(grant, contact=_contact_facts(account_id=None)),
|
||||
_email_proof(grant),
|
||||
)
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == EmailAddressSubmissionActor(_EMAIL)
|
||||
|
||||
|
||||
def test_current_im_binding_resolves_only_to_the_current_dify_account() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
endpoint = _im_endpoint(grant)
|
||||
|
||||
decision = _authorize(
|
||||
_context(grant, endpoint=endpoint, contact=_contact_facts(), im=_im_facts()),
|
||||
_im_proof(),
|
||||
)
|
||||
|
||||
assert decision.rejection is None
|
||||
assert decision.authorized is not None
|
||||
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
|
||||
|
||||
|
||||
def test_verified_proof_scoped_to_another_grant_is_rejected() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
other_grant = ApproverGrant(
|
||||
ref=_FORM_REF.grant(ApproverGrantId("grant-2")),
|
||||
subject=grant.subject,
|
||||
subject_key=grant.subject_key,
|
||||
matched_sources=(),
|
||||
subject_snapshot=grant.subject_snapshot,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
decision = _authorize(_context(grant, contact=_contact_facts()), _email_proof(other_grant))
|
||||
|
||||
assert decision.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("contact", "expected"),
|
||||
[
|
||||
(None, SubmissionAuthorizationRejection.STALE_IDENTITY),
|
||||
(_contact_facts(account_active=False), SubmissionAuthorizationRejection.ACCOUNT_DISABLED),
|
||||
(
|
||||
_contact_facts(contact_id=ContactId("recreated-contact")),
|
||||
SubmissionAuthorizationRejection.STALE_IDENTITY,
|
||||
),
|
||||
(
|
||||
_contact_facts(normalized_email=NormalizedEmail("changed@example.com")),
|
||||
SubmissionAuthorizationRejection.STALE_IDENTITY,
|
||||
),
|
||||
(
|
||||
_contact_facts(workspace_available=False),
|
||||
SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_contact_email_proof_revalidates_every_current_identity_fact(
|
||||
contact: CurrentContactAuthorizationFacts | None,
|
||||
expected: SubmissionAuthorizationRejection,
|
||||
) -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(_context(grant, contact=contact), _email_proof(grant))
|
||||
|
||||
assert decision.rejection is expected
|
||||
|
||||
|
||||
def test_changed_effective_im_binding_rejects_historical_im_proof() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
endpoint = _im_endpoint(grant)
|
||||
|
||||
decision = _authorize(
|
||||
_context(
|
||||
grant,
|
||||
endpoint=endpoint,
|
||||
contact=_contact_facts(),
|
||||
im=_im_facts(binding_id=IMBindingId("replacement-binding")),
|
||||
),
|
||||
_im_proof(),
|
||||
)
|
||||
|
||||
assert decision.rejection is SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected"),
|
||||
[
|
||||
(HumanInputV2FormStatus.SUBMITTED, SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED),
|
||||
(HumanInputV2FormStatus.TIMEOUT, SubmissionAuthorizationRejection.FORM_TIMED_OUT),
|
||||
(HumanInputV2FormStatus.EXPIRED, SubmissionAuthorizationRejection.FORM_STATUS_EXPIRED),
|
||||
],
|
||||
)
|
||||
def test_inactive_form_states_remain_stable_and_transport_neutral(
|
||||
status: HumanInputV2FormStatus,
|
||||
expected: SubmissionAuthorizationRejection,
|
||||
) -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(_context(grant, contact=_contact_facts(), status=status), _email_proof(grant))
|
||||
|
||||
assert decision.rejection is expected
|
||||
|
||||
|
||||
def test_authorization_context_is_an_immutable_snapshot_without_version_or_reload_hooks() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
mutable_current_state = {"account_active": True, "workspace_available": True}
|
||||
context = _context(
|
||||
grant,
|
||||
contact=_contact_facts(
|
||||
account_active=mutable_current_state["account_active"],
|
||||
workspace_available=mutable_current_state["workspace_available"],
|
||||
),
|
||||
)
|
||||
mutable_current_state["account_active"] = False
|
||||
mutable_current_state["workspace_available"] = False
|
||||
|
||||
decision = _authorize(context, _email_proof(grant))
|
||||
|
||||
assert decision.rejection is None
|
||||
assert {field.name for field in fields(AuthorizationContext)}.isdisjoint(
|
||||
{"contact_version", "binding_version", "reload", "loader"}
|
||||
)
|
||||
assert AuthorizationContext.__dataclass_params__.frozen is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider_tenant_id", "provider_user_id"),
|
||||
[("", "provider-user-1"), ("provider-tenant-1", " ")],
|
||||
)
|
||||
def test_verified_im_proof_rejects_blank_provider_identity(
|
||||
provider_tenant_id: str,
|
||||
provider_user_id: str,
|
||||
) -> None:
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
VerifiedIMIdentityProof(
|
||||
integration_id=_INTEGRATION_ID,
|
||||
identity_id=_IM_IDENTITY_ID,
|
||||
binding_id=_IM_BINDING_ID,
|
||||
provider=IMProvider.SLACK,
|
||||
provider_tenant_id=provider_tenant_id,
|
||||
provider_user_id=provider_user_id,
|
||||
)
|
||||
|
||||
|
||||
def test_authorization_context_rejects_grant_or_endpoint_from_another_owner() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
other_grant = ApproverGrant(
|
||||
ref=_FORM_REF.grant(ApproverGrantId("grant-2")),
|
||||
subject=EndUserApprovalSubject(_END_USER_ID),
|
||||
subject_key=CanonicalSubjectKey.for_end_user(_END_USER_ID),
|
||||
matched_sources=(),
|
||||
subject_snapshot=SubjectSnapshot("Other Reviewer", None),
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="grant does not belong"):
|
||||
AuthorizationContext(_form(grant), other_grant, None, None, None, None)
|
||||
with pytest.raises(ValueError, match="endpoint does not belong"):
|
||||
AuthorizationContext(_form(grant), grant, _im_endpoint(other_grant), None, None, None)
|
||||
|
||||
|
||||
def test_authorization_decision_requires_exactly_one_outcome() -> None:
|
||||
with pytest.raises(ValueError, match="exactly one outcome"):
|
||||
SubmissionAuthorizationDecision(None, None)
|
||||
|
||||
|
||||
def test_invalid_selected_action_returns_transport_neutral_rejection() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(
|
||||
_context(grant, contact=_contact_facts()),
|
||||
VerifiedAccountSessionProof(_ACCOUNT_ID),
|
||||
selected_action_id="unknown",
|
||||
)
|
||||
|
||||
assert decision.rejection is SubmissionAuthorizationRejection.INVALID_SELECTED_ACTION
|
||||
|
||||
|
||||
def test_waiting_form_past_global_expiry_returns_global_expired_rejection() -> None:
|
||||
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
decision = _authorize(
|
||||
_context(grant, contact=_contact_facts()),
|
||||
VerifiedAccountSessionProof(_ACCOUNT_ID),
|
||||
now=UtcTimestamp(_NOW.value + timedelta(hours=3)),
|
||||
)
|
||||
|
||||
assert decision.rejection is SubmissionAuthorizationRejection.FORM_GLOBALLY_EXPIRED
|
||||
|
||||
|
||||
def test_account_session_requires_contact_grant_current_contact_and_matching_account() -> None:
|
||||
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
|
||||
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
wrong_subject = _authorize(_context(end_user_grant), VerifiedAccountSessionProof(_ACCOUNT_ID))
|
||||
missing_contact = _authorize(_context(contact_grant), VerifiedAccountSessionProof(_ACCOUNT_ID))
|
||||
wrong_account = _authorize(
|
||||
_context(contact_grant, contact=_contact_facts()),
|
||||
VerifiedAccountSessionProof(AccountId("account-2")),
|
||||
)
|
||||
|
||||
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
assert missing_contact.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
assert wrong_account.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
|
||||
|
||||
def test_trusted_end_user_requires_end_user_grant_current_owner_and_workspace() -> None:
|
||||
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
|
||||
proof = VerifiedTrustedEndUserProof(_END_USER_ID, AppId("app-1"))
|
||||
|
||||
wrong_subject = _authorize(_context(contact_grant, contact=_contact_facts()), proof)
|
||||
missing_end_user = _authorize(_context(end_user_grant), proof)
|
||||
unavailable_workspace = _authorize(
|
||||
_context(
|
||||
end_user_grant,
|
||||
end_user=CurrentEndUserAuthorizationFacts(_END_USER_ID, AppId("app-1"), False),
|
||||
),
|
||||
proof,
|
||||
)
|
||||
|
||||
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
assert missing_end_user.rejection is SubmissionAuthorizationRejection.END_USER_UNAVAILABLE
|
||||
assert unavailable_workspace.rejection is SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
|
||||
|
||||
|
||||
def test_email_proof_requires_matching_subject_kind_and_contact_incarnation() -> None:
|
||||
standalone_grant = _grant(EmailAddressApprovalSubject(_EMAIL))
|
||||
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
standalone_with_contact_proof = _authorize(_context(standalone_grant), _email_proof(standalone_grant))
|
||||
contact_with_email_address_proof = _authorize(
|
||||
_context(contact_grant, contact=_contact_facts()),
|
||||
_email_proof(contact_grant, contact_id=None),
|
||||
)
|
||||
changed_contact_incarnation = _authorize(
|
||||
_context(contact_grant, contact=_contact_facts()),
|
||||
_email_proof(contact_grant, contact_id=ContactId("contact-2")),
|
||||
)
|
||||
|
||||
assert standalone_with_contact_proof.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
assert contact_with_email_address_proof.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
assert changed_contact_incarnation.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
|
||||
|
||||
def test_im_proof_requires_contact_account_and_current_effective_binding() -> None:
|
||||
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
|
||||
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
|
||||
|
||||
wrong_subject = _authorize(_context(end_user_grant), _im_proof())
|
||||
missing_contact = _authorize(_context(contact_grant), _im_proof())
|
||||
contact_without_account = _authorize(
|
||||
_context(contact_grant, contact=_contact_facts(account_id=None)),
|
||||
_im_proof(),
|
||||
)
|
||||
missing_binding = _authorize(
|
||||
_context(contact_grant, contact=_contact_facts()),
|
||||
_im_proof(),
|
||||
)
|
||||
|
||||
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
|
||||
assert missing_contact.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
assert contact_without_account.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
|
||||
assert missing_binding.rejection is SubmissionAuthorizationRejection.IM_BINDING_CHANGED
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Domain tests for canonical Contact identity and ownership."""
|
||||
|
||||
from dataclasses import FrozenInstanceError, replace
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactIdentitySource,
|
||||
ContactRejectionCode,
|
||||
ExternalContactOwner,
|
||||
OrganizationAccountOwner,
|
||||
WorkspaceMemberOwner,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, tzinfo=UTC))
|
||||
|
||||
|
||||
def test_organization_account_contact_has_deployment_owner() -> None:
|
||||
contact = Contact.organization_account(
|
||||
contact_id=ContactId("contact-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email="ADA@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert contact.identity_source is ContactIdentitySource.ORGANIZATION_ACCOUNT
|
||||
assert contact.owner == OrganizationAccountOwner(AccountId("account-1"))
|
||||
assert contact.normalized_email is not None
|
||||
assert contact.normalized_email.value == "ada@example.com"
|
||||
|
||||
|
||||
def test_workspace_member_contact_has_workspace_and_account_owner() -> None:
|
||||
contact = Contact.workspace_member(
|
||||
contact_id=ContactId("contact-1"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert contact.identity_source is ContactIdentitySource.WORKSPACE_MEMBER
|
||||
assert contact.owner == WorkspaceMemberOwner(WorkspaceId("workspace-1"), AccountId("account-1"))
|
||||
|
||||
|
||||
def test_external_contact_requires_workspace_and_email() -> None:
|
||||
contact = Contact.external(
|
||||
contact_id=ContactId("contact-1"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert contact.identity_source is ContactIdentitySource.EXTERNAL
|
||||
assert contact.owner == ExternalContactOwner(WorkspaceId("workspace-1"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("identity_source", "owner"),
|
||||
[
|
||||
(ContactIdentitySource.ORGANIZATION_ACCOUNT, ExternalContactOwner(WorkspaceId("workspace-1"))),
|
||||
(ContactIdentitySource.WORKSPACE_MEMBER, OrganizationAccountOwner(AccountId("account-1"))),
|
||||
(
|
||||
ContactIdentitySource.EXTERNAL,
|
||||
WorkspaceMemberOwner(WorkspaceId("workspace-1"), AccountId("account-1")),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_identity_source_owner_combination_is_rejected(identity_source, owner) -> None:
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
Contact.create(
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_source=identity_source,
|
||||
owner=owner,
|
||||
name="Ada",
|
||||
email="ada@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert error.value.code is ContactRejectionCode.INVALID_OWNER
|
||||
|
||||
|
||||
def test_external_contact_without_email_is_rejected() -> None:
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
Contact.create(
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_source=ContactIdentitySource.EXTERNAL,
|
||||
owner=ExternalContactOwner(WorkspaceId("workspace-1")),
|
||||
name="Reviewer",
|
||||
email=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
|
||||
|
||||
|
||||
def test_identity_source_is_immutable_across_profile_changes() -> None:
|
||||
contact = Contact.organization_account(
|
||||
contact_id=ContactId("contact-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email="ada@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
contact.identity_source = ContactIdentitySource.EXTERNAL # type: ignore[misc]
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
replace(contact, identity_source=ContactIdentitySource.EXTERNAL)
|
||||
assert error.value.code is ContactRejectionCode.INVALID_OWNER
|
||||
|
||||
|
||||
def test_contact_directory_scope_is_derived_from_immutable_owner() -> None:
|
||||
organization = Contact.organization_account(
|
||||
contact_id=ContactId("organization"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email=None,
|
||||
now=_NOW,
|
||||
)
|
||||
external = Contact.external(
|
||||
contact_id=ContactId("external"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert organization.directory_scope == DeploymentScope()
|
||||
assert external.directory_scope == WorkspaceScope(WorkspaceId("workspace-1"))
|
||||
|
||||
|
||||
def test_contact_rejects_blank_name_and_inconsistent_email_values() -> None:
|
||||
with pytest.raises(ContactDirectoryError) as blank_name:
|
||||
Contact.organization_account(
|
||||
contact_id=ContactId("contact-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name=" ",
|
||||
email=None,
|
||||
now=_NOW,
|
||||
)
|
||||
assert blank_name.value.code is ContactRejectionCode.INVALID_NAME
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as invalid_email:
|
||||
Contact.organization_account(
|
||||
contact_id=ContactId("contact-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email="invalid",
|
||||
now=_NOW,
|
||||
)
|
||||
assert invalid_email.value.code is ContactRejectionCode.INVALID_EMAIL
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as inconsistent_email:
|
||||
Contact(
|
||||
id=ContactId("contact-1"),
|
||||
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
owner=OrganizationAccountOwner(AccountId("account-1")),
|
||||
name=" Ada ",
|
||||
normalized_name="wrong",
|
||||
email="ada@example.com",
|
||||
normalized_email=NormalizedEmail("different@example.com"),
|
||||
avatar_file_id=None,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
assert inconsistent_email.value.code is ContactRejectionCode.INVALID_EMAIL
|
||||
|
||||
|
||||
def test_contact_rejects_normalized_email_without_deliverable_email() -> None:
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
Contact(
|
||||
id=ContactId("contact-1"),
|
||||
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
owner=OrganizationAccountOwner(AccountId("account-1")),
|
||||
name="Ada",
|
||||
normalized_name="ada",
|
||||
email=None,
|
||||
normalized_email=NormalizedEmail("ada@example.com"),
|
||||
avatar_file_id=None,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
|
||||
|
||||
|
||||
def test_contact_canonicalizes_persisted_name_values() -> None:
|
||||
contact = Contact(
|
||||
id=ContactId("contact-1"),
|
||||
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
|
||||
owner=OrganizationAccountOwner(AccountId("account-1")),
|
||||
name=" Ada ",
|
||||
normalized_name="stale-name",
|
||||
email=None,
|
||||
normalized_email=None,
|
||||
avatar_file_id=None,
|
||||
created_at=_NOW,
|
||||
updated_at=_NOW,
|
||||
)
|
||||
|
||||
assert contact.name == "Ada"
|
||||
assert contact.normalized_name == "ada"
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for stable Contact Directory rejection contracts."""
|
||||
|
||||
from core.human_input_v2.contact_directory import ContactDirectoryError, ContactRejection, ContactRejectionCode
|
||||
|
||||
|
||||
def test_contact_rejection_is_transport_neutral_and_stable() -> None:
|
||||
rejection = ContactRejection(ContactRejectionCode.CONFLICTING_IDENTITY)
|
||||
error = ContactDirectoryError(rejection)
|
||||
|
||||
assert rejection.to_primitive() == {"reason": "conflicting_identity"}
|
||||
assert error.code is ContactRejectionCode.CONFLICTING_IDENTITY
|
||||
assert str(error) == "conflicting_identity"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Table-driven tests for Contact Directory resolution and lifecycle policy."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.contact_directory import (
|
||||
Contact,
|
||||
ContactDirectoryError,
|
||||
ContactDirectoryPolicy,
|
||||
ContactDirectorySnapshot,
|
||||
ContactRejectionCode,
|
||||
ContactResolution,
|
||||
)
|
||||
from core.human_input_v2.shared import AccountId, ContactId, UtcTimestamp, WorkspaceId
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, tzinfo=UTC))
|
||||
_WORKSPACE_ID = WorkspaceId("workspace-1")
|
||||
|
||||
|
||||
def _organization_contact() -> Contact:
|
||||
return Contact.organization_account(
|
||||
contact_id=ContactId("organization-contact"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Ada",
|
||||
email="ada@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _workspace_member_contact() -> Contact:
|
||||
return Contact.workspace_member(
|
||||
contact_id=ContactId("member-contact"),
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
account_id=AccountId("account-2"),
|
||||
name="Grace",
|
||||
email="grace@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("contact_factory", "member_accounts", "platform_contacts", "unavailable_accounts", "expected"),
|
||||
[
|
||||
(_organization_contact, {AccountId("account-1")}, set(), set(), ContactResolution.WORKSPACE),
|
||||
(_organization_contact, set(), {ContactId("organization-contact")}, set(), ContactResolution.PLATFORM),
|
||||
(_organization_contact, set(), set(), set(), ContactResolution.ABSENT),
|
||||
(
|
||||
_organization_contact,
|
||||
{AccountId("account-1")},
|
||||
{ContactId("organization-contact")},
|
||||
{AccountId("account-1")},
|
||||
ContactResolution.ABSENT,
|
||||
),
|
||||
(_workspace_member_contact, {AccountId("account-2")}, set(), set(), ContactResolution.WORKSPACE),
|
||||
(_workspace_member_contact, set(), set(), set(), ContactResolution.ABSENT),
|
||||
],
|
||||
)
|
||||
def test_account_contact_resolution(
|
||||
contact_factory,
|
||||
member_accounts: set[AccountId],
|
||||
platform_contacts: set[ContactId],
|
||||
unavailable_accounts: set[AccountId],
|
||||
expected: ContactResolution,
|
||||
) -> None:
|
||||
contact = contact_factory()
|
||||
snapshot = ContactDirectorySnapshot(
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contacts=(contact,),
|
||||
member_account_ids=frozenset(member_accounts),
|
||||
platform_contact_ids=frozenset(platform_contacts),
|
||||
unavailable_account_ids=frozenset(unavailable_accounts),
|
||||
)
|
||||
|
||||
assert ContactDirectoryPolicy.resolve_for_workspace(snapshot, contact.id) is expected
|
||||
assert contact == contact_factory()
|
||||
|
||||
|
||||
def test_external_contact_resolves_only_in_its_owner_workspace() -> None:
|
||||
contact = Contact.external(
|
||||
contact_id=ContactId("external-contact"),
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
owner_snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(contact,))
|
||||
other_snapshot = ContactDirectorySnapshot(workspace_id=WorkspaceId("workspace-2"), contacts=(contact,))
|
||||
|
||||
assert ContactDirectoryPolicy.resolve_for_workspace(owner_snapshot, contact.id) is ContactResolution.EXTERNAL
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.resolve_for_workspace(other_snapshot, contact.id)
|
||||
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
|
||||
|
||||
|
||||
def test_missing_contact_resolves_absent() -> None:
|
||||
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
|
||||
|
||||
assert ContactDirectoryPolicy.resolve_for_workspace(snapshot, ContactId("missing")) is ContactResolution.ABSENT
|
||||
|
||||
|
||||
def test_external_admission_rejects_normalized_email_collision() -> None:
|
||||
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(_organization_contact(),))
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.admit_external(
|
||||
snapshot,
|
||||
contact_id=ContactId("external-contact"),
|
||||
name="Duplicate",
|
||||
email=" ADA@EXAMPLE.COM ",
|
||||
now=_NOW,
|
||||
)
|
||||
assert error.value.code is ContactRejectionCode.CONFLICTING_IDENTITY
|
||||
|
||||
|
||||
def test_deleted_external_email_can_be_recreated_with_a_new_contact_id() -> None:
|
||||
empty_after_deletion = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
|
||||
|
||||
recreated = ContactDirectoryPolicy.admit_external(
|
||||
empty_after_deletion,
|
||||
contact_id=ContactId("new-contact"),
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert recreated.id == ContactId("new-contact")
|
||||
|
||||
|
||||
def test_hard_deletion_rejects_cross_organization_contact() -> None:
|
||||
contact = Contact.external(
|
||||
contact_id=ContactId("external-contact"),
|
||||
workspace_id=WorkspaceId("workspace-2"),
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.ensure_external_deletable(contact, _WORKSPACE_ID)
|
||||
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
|
||||
|
||||
|
||||
def test_hard_deletion_accepts_contact_from_owner_workspace_without_mutation() -> None:
|
||||
contact = Contact.external(
|
||||
contact_id=ContactId("external-contact"),
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
ContactDirectoryPolicy.ensure_external_deletable(contact, _WORKSPACE_ID)
|
||||
|
||||
assert contact.owner.workspace_id == _WORKSPACE_ID
|
||||
|
||||
|
||||
def test_workspace_member_resolution_rejects_cross_organization_use() -> None:
|
||||
contact = _workspace_member_contact()
|
||||
snapshot = ContactDirectorySnapshot(workspace_id=WorkspaceId("workspace-2"), contacts=(contact,))
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.resolve_for_workspace(snapshot, contact.id)
|
||||
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
|
||||
|
||||
|
||||
def test_external_admission_rejects_invalid_email() -> None:
|
||||
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
|
||||
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.admit_external(
|
||||
snapshot,
|
||||
contact_id=ContactId("external-contact"),
|
||||
name="Reviewer",
|
||||
email="invalid",
|
||||
now=_NOW,
|
||||
)
|
||||
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
|
||||
|
||||
|
||||
def test_hard_deletion_rejects_account_backed_contact() -> None:
|
||||
with pytest.raises(ContactDirectoryError) as error:
|
||||
ContactDirectoryPolicy.ensure_external_deletable(_organization_contact(), _WORKSPACE_ID)
|
||||
assert error.value.code is ContactRejectionCode.INVALID_OWNER
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Effective IM binding resolution tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.human_input_v2.contact_directory import Contact, ContactSnapshot
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider
|
||||
from core.human_input_v2.im_integration import (
|
||||
BindingResolutionKind,
|
||||
EffectiveBindingResolver,
|
||||
IMBinding,
|
||||
IMIdentity,
|
||||
IntegrationRevisionToken,
|
||||
ProviderTenantIdentity,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_INTEGRATION_ID = IntegrationId("integration-1")
|
||||
_WORKSPACE_ID = WorkspaceId("workspace-1")
|
||||
|
||||
|
||||
def _contact(email: str | None = "reviewer@example.com") -> ContactSnapshot:
|
||||
return ContactSnapshot(
|
||||
contact=Contact.organization_account(
|
||||
contact_id=ContactId("contact-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Reviewer",
|
||||
email=email,
|
||||
now=_NOW,
|
||||
),
|
||||
account_available=True,
|
||||
)
|
||||
|
||||
|
||||
def _identity(identity_id: str, provider_user_id: str, email: str = "reviewer@example.com") -> IMIdentity:
|
||||
return IMIdentity.create(
|
||||
identity_id=IMIdentityId(identity_id),
|
||||
integration_id=_INTEGRATION_ID,
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_user_id=provider_user_id,
|
||||
display_name="Reviewer",
|
||||
email=email,
|
||||
raw_payload={"secret-provider-fact": "must-not-leak"},
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=_NOW,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _binding(binding_id: str, identity: IMIdentity, scope: IMBindingScope) -> IMBinding:
|
||||
return IMBinding.create(
|
||||
binding_id=IMBindingId(binding_id),
|
||||
integration_id=_INTEGRATION_ID,
|
||||
scope=scope,
|
||||
scope_id=str(_WORKSPACE_ID if scope is IMBindingScope.WORKSPACE else _INTEGRATION_ID),
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_id=identity.id,
|
||||
provider=IMProvider.FEISHU,
|
||||
bound_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def _resolve(*, identities: tuple[IMIdentity, ...], bindings: tuple[IMBinding, ...]):
|
||||
return EffectiveBindingResolver.resolve(
|
||||
integration_revision=IntegrationRevisionToken(_INTEGRATION_ID, 1),
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contact=_contact(),
|
||||
identities=identities,
|
||||
bindings=bindings,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_binding_has_priority_over_organization_binding() -> None:
|
||||
organization_identity = _identity("identity-org", "org-user")
|
||||
workspace_identity = _identity("identity-workspace", "workspace-user")
|
||||
|
||||
result = _resolve(
|
||||
identities=(organization_identity, workspace_identity),
|
||||
bindings=(
|
||||
_binding("binding-org", organization_identity, IMBindingScope.ORGANIZATION),
|
||||
_binding("binding-workspace", workspace_identity, IMBindingScope.WORKSPACE),
|
||||
),
|
||||
)
|
||||
|
||||
assert result.kind is BindingResolutionKind.WORKSPACE_OVERRIDE
|
||||
assert result.binding is not None
|
||||
assert result.binding.provider_user_id == "workspace-user"
|
||||
|
||||
|
||||
def test_reset_to_global_is_represented_by_absent_workspace_override() -> None:
|
||||
organization_identity = _identity("identity-org", "org-user")
|
||||
|
||||
result = _resolve(
|
||||
identities=(organization_identity,),
|
||||
bindings=(_binding("binding-org", organization_identity, IMBindingScope.ORGANIZATION),),
|
||||
)
|
||||
|
||||
assert result.kind is BindingResolutionKind.ORGANIZATION_BINDING
|
||||
assert result.binding is not None
|
||||
assert result.binding.binding_id == IMBindingId("binding-org")
|
||||
|
||||
|
||||
def test_normalized_email_fallback_is_used_when_no_binding_exists() -> None:
|
||||
identity = _identity("identity-1", "provider-user-1", " REVIEWER@EXAMPLE.COM ")
|
||||
|
||||
result = _resolve(identities=(identity,), bindings=())
|
||||
|
||||
assert result.kind is BindingResolutionKind.EMAIL_FALLBACK
|
||||
assert result.binding is not None
|
||||
assert result.binding.binding_id is None
|
||||
assert result.binding.account_id == AccountId("account-1")
|
||||
assert not hasattr(result.binding, "raw_payload")
|
||||
assert not hasattr(result.binding, "encrypted_credentials")
|
||||
|
||||
|
||||
def test_missing_email_or_matching_identity_returns_not_available() -> None:
|
||||
without_email = EffectiveBindingResolver.resolve(
|
||||
integration_revision=IntegrationRevisionToken(_INTEGRATION_ID, 1),
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
|
||||
workspace_id=_WORKSPACE_ID,
|
||||
contact=_contact(None),
|
||||
identities=(),
|
||||
bindings=(),
|
||||
)
|
||||
without_identity = _resolve(identities=(), bindings=())
|
||||
|
||||
assert without_email.kind is BindingResolutionKind.NOT_AVAILABLE
|
||||
assert without_identity.kind is BindingResolutionKind.NOT_AVAILABLE
|
||||
|
||||
|
||||
def test_integration_or_provider_mismatch_returns_stable_rejection_without_binding() -> None:
|
||||
identity = IMIdentity.create(
|
||||
identity_id=IMIdentityId("identity-other"),
|
||||
integration_id=IntegrationId("integration-other"),
|
||||
provider=IMProvider.SLACK,
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
raw_payload={},
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=_NOW,
|
||||
now=_NOW,
|
||||
)
|
||||
binding = IMBinding.create(
|
||||
binding_id=IMBindingId("binding-other"),
|
||||
integration_id=_INTEGRATION_ID,
|
||||
scope=IMBindingScope.WORKSPACE,
|
||||
scope_id=str(_WORKSPACE_ID),
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_id=identity.id,
|
||||
provider=IMProvider.FEISHU,
|
||||
bound_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
result = _resolve(identities=(identity,), bindings=(binding,))
|
||||
|
||||
assert result.kind is BindingResolutionKind.INVALID_BINDING
|
||||
assert result.binding is None
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Domain tests for IM Integration configuration revision semantics."""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.entities import IMBindingScope, IMIntegrationStatus, IMProvider
|
||||
from core.human_input_v2.im_integration import (
|
||||
ConfigurationTransitionKind,
|
||||
EncryptedCredentials,
|
||||
IMBinding,
|
||||
IMIdentity,
|
||||
IMIntegration,
|
||||
IntegrationRevisionToken,
|
||||
OpaqueProviderPayload,
|
||||
ProviderTenantIdentity,
|
||||
StaleRevision,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IntegrationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_LATER = UtcTimestamp(datetime(2026, 7, 25, 9, tzinfo=UTC))
|
||||
|
||||
|
||||
def _credentials(secret: str) -> EncryptedCredentials:
|
||||
return EncryptedCredentials.from_mapping({"app_id": "app-1", "encrypted_app_secret": secret})
|
||||
|
||||
|
||||
def _integration() -> IMIntegration:
|
||||
return IMIntegration.create(
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
|
||||
encrypted_credentials=_credentials("ciphertext-1"),
|
||||
configured_by_account_id=AccountId("account-1"),
|
||||
callback_url="https://example.com/callback",
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_first_creation_owns_complete_revision_token() -> None:
|
||||
integration = _integration()
|
||||
|
||||
assert integration.revision == IntegrationRevisionToken(IntegrationId("integration-1"), 1)
|
||||
assert integration.status is IMIntegrationStatus.CONFIGURED
|
||||
assert integration.encrypted_credentials.to_mapping()["encrypted_app_secret"] == "ciphertext-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_version", [0, -1])
|
||||
def test_revision_token_requires_positive_version(config_version: int) -> None:
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
IntegrationRevisionToken(IntegrationId("integration-1"), config_version)
|
||||
|
||||
|
||||
def test_opaque_values_reject_empty_or_non_object_payloads() -> None:
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
EncryptedCredentials.from_mapping({})
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
EncryptedCredentials("[]").to_mapping()
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
OpaqueProviderPayload("[]").to_mapping()
|
||||
|
||||
|
||||
def test_provider_tenant_and_integration_require_valid_persisted_values() -> None:
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
ProviderTenantIdentity(IMProvider.FEISHU, " ")
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
replace(_integration(), config_version=0)
|
||||
|
||||
|
||||
def test_identity_and_binding_reject_invalid_current_state() -> None:
|
||||
with pytest.raises(ValueError, match="provider user id"):
|
||||
IMIdentity.create(
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_user_id=" ",
|
||||
display_name=None,
|
||||
email=None,
|
||||
raw_payload={},
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=None,
|
||||
now=_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="scope id"):
|
||||
IMBinding.create(
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
scope=IMBindingScope.WORKSPACE,
|
||||
scope_id=" ",
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
bound_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
with pytest.raises(ValueError, match="organization binding scope"):
|
||||
IMBinding.create(
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
integration_id=IntegrationId("integration-1"),
|
||||
scope=IMBindingScope.ORGANIZATION,
|
||||
scope_id="integration-other",
|
||||
contact_id=ContactId("contact-1"),
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
provider=IMProvider.FEISHU,
|
||||
bound_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_confirmed_credential_rotation_advances_once_and_preserves_current_state() -> None:
|
||||
integration = _integration()
|
||||
|
||||
decision = integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
encrypted_credentials=_credentials("ciphertext-2"),
|
||||
configured_by_account_id=AccountId("account-2"),
|
||||
callback_url="https://example.com/new-callback",
|
||||
now=_LATER,
|
||||
)
|
||||
|
||||
assert decision.kind is ConfigurationTransitionKind.CREDENTIAL_ROTATION
|
||||
assert decision.integration.id == integration.id
|
||||
assert decision.integration.revision.config_version == 2
|
||||
assert decision.invalidation.invalidate_identities is False
|
||||
assert decision.invalidation.invalidate_bindings is False
|
||||
|
||||
|
||||
def test_credential_rotation_rejects_replacement_identity() -> None:
|
||||
integration = _integration()
|
||||
|
||||
with pytest.raises(ValueError, match="must preserve integration identity"):
|
||||
integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
encrypted_credentials=_credentials("ciphertext-2"),
|
||||
configured_by_account_id=None,
|
||||
callback_url=None,
|
||||
now=_LATER,
|
||||
replacement_integration_id=IntegrationId("integration-2"),
|
||||
)
|
||||
|
||||
|
||||
def test_provider_tenant_replacement_gets_new_identity_and_invalidates_current_state() -> None:
|
||||
integration = _integration()
|
||||
|
||||
decision = integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.SLACK, "provider-tenant-2"),
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping(
|
||||
{
|
||||
"client_id": "client-1",
|
||||
"encrypted_client_secret": "ciphertext-2",
|
||||
"encrypted_signing_secret": "ciphertext-3",
|
||||
"encrypted_bot_token": "ciphertext-4",
|
||||
}
|
||||
),
|
||||
configured_by_account_id=AccountId("account-2"),
|
||||
callback_url=None,
|
||||
now=_LATER,
|
||||
replacement_integration_id=IntegrationId("integration-2"),
|
||||
)
|
||||
|
||||
assert decision.kind is ConfigurationTransitionKind.PROVIDER_REPLACEMENT
|
||||
assert decision.integration.id == IntegrationId("integration-2")
|
||||
assert decision.integration.revision.config_version == 1
|
||||
assert decision.invalidation.invalidate_identities is True
|
||||
assert decision.invalidation.invalidate_bindings is True
|
||||
|
||||
stale = decision.integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=decision.integration.provider_tenant,
|
||||
encrypted_credentials=decision.integration.encrypted_credentials,
|
||||
configured_by_account_id=AccountId("account-2"),
|
||||
callback_url=None,
|
||||
now=_LATER,
|
||||
)
|
||||
assert stale == StaleRevision(expected=integration.revision, actual=decision.integration.revision)
|
||||
|
||||
|
||||
def test_provider_replacement_requires_new_integration_identity() -> None:
|
||||
integration = _integration()
|
||||
|
||||
with pytest.raises(ValueError, match="requires a new integration identity"):
|
||||
integration.reconfigure(
|
||||
expected_revision=integration.revision,
|
||||
provider_tenant=ProviderTenantIdentity(IMProvider.SLACK, "provider-tenant-2"),
|
||||
encrypted_credentials=EncryptedCredentials.from_mapping(
|
||||
{
|
||||
"client_id": "client-1",
|
||||
"encrypted_client_secret": "ciphertext-2",
|
||||
"encrypted_signing_secret": "ciphertext-3",
|
||||
"encrypted_bot_token": "ciphertext-4",
|
||||
}
|
||||
),
|
||||
configured_by_account_id=None,
|
||||
callback_url=None,
|
||||
now=_LATER,
|
||||
)
|
||||
|
||||
|
||||
def test_stale_update_and_delete_return_stable_results() -> None:
|
||||
integration = _integration()
|
||||
stale_token = IntegrationRevisionToken(integration.id, 9)
|
||||
|
||||
update = integration.reconfigure(
|
||||
expected_revision=stale_token,
|
||||
provider_tenant=integration.provider_tenant,
|
||||
encrypted_credentials=_credentials("ciphertext-2"),
|
||||
configured_by_account_id=AccountId("account-2"),
|
||||
callback_url=None,
|
||||
now=_LATER,
|
||||
)
|
||||
deletion = integration.plan_deletion(stale_token)
|
||||
|
||||
expected = StaleRevision(expected=stale_token, actual=integration.revision)
|
||||
assert update == expected
|
||||
assert deletion == expected
|
||||
|
||||
|
||||
def test_connectivity_diagnostics_do_not_advance_configuration_revision() -> None:
|
||||
integration = _integration()
|
||||
|
||||
updated = integration.record_diagnostics(
|
||||
status=IMIntegrationStatus.CONNECTED,
|
||||
safe_status_reason="Connection verified",
|
||||
checked_at=_LATER,
|
||||
)
|
||||
|
||||
assert updated.revision == integration.revision
|
||||
assert updated.status is IMIntegrationStatus.CONNECTED
|
||||
assert updated.last_checked_at == _LATER
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shape tests for transaction-oriented IM persistence ports."""
|
||||
|
||||
from core.human_input_v2.im_integration import IMControlPlaneRepository
|
||||
|
||||
|
||||
def test_repository_port_is_transaction_oriented_instead_of_table_crud() -> None:
|
||||
public_methods = {
|
||||
name for name, value in vars(IMControlPlaneRepository).items() if callable(value) and not name.startswith("_")
|
||||
}
|
||||
|
||||
assert {
|
||||
"create_integration",
|
||||
"compare_and_swap_configuration",
|
||||
"compare_and_swap_delete",
|
||||
"create_or_get_active_run",
|
||||
"load_reconciliation_snapshot",
|
||||
"apply_reconciliation",
|
||||
"resolve_effective_binding",
|
||||
"append_sync_results",
|
||||
} <= public_methods
|
||||
assert not {"create", "read", "update", "delete"} & public_methods
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Pure reconciliation tests for the IM Control Plane."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from core.human_input_v2.contact_directory import Contact, ContactSnapshot
|
||||
from core.human_input_v2.entities import IMBindingScope, IMProvider, IMSyncRunStatus
|
||||
from core.human_input_v2.im_integration import (
|
||||
IMBinding,
|
||||
IMIdentity,
|
||||
IMSyncRun,
|
||||
IntegrationRevisionToken,
|
||||
MatchKind,
|
||||
ProviderDirectoryEntry,
|
||||
ReconciliationSnapshot,
|
||||
SyncReconciler,
|
||||
)
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
IMBindingId,
|
||||
IMIdentityId,
|
||||
IMSyncRunId,
|
||||
IntegrationId,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
)
|
||||
|
||||
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
|
||||
_REVISION = IntegrationRevisionToken(IntegrationId("integration-1"), 3)
|
||||
|
||||
|
||||
def _contact(contact_id: str, account_id: str, email: str, *, available: bool = True) -> ContactSnapshot:
|
||||
return ContactSnapshot(
|
||||
contact=Contact.organization_account(
|
||||
contact_id=ContactId(contact_id),
|
||||
account_id=AccountId(account_id),
|
||||
name=account_id,
|
||||
email=email,
|
||||
now=_NOW,
|
||||
),
|
||||
account_available=available,
|
||||
)
|
||||
|
||||
|
||||
def test_sync_run_captures_complete_integration_revision() -> None:
|
||||
run = IMSyncRun.create(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=AccountId("account-1"),
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
assert run.integration_revision == _REVISION
|
||||
assert run.status is IMSyncRunStatus.QUEUED
|
||||
assert run.is_active is True
|
||||
|
||||
|
||||
def test_sync_run_start_is_idempotent_after_leaving_queued_state() -> None:
|
||||
run = IMSyncRun.create(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
started_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
started = run.start(_NOW)
|
||||
|
||||
assert started.status is IMSyncRunStatus.RUNNING
|
||||
assert started.start(_NOW) is started
|
||||
|
||||
|
||||
def test_reconciler_prefers_provider_user_id_before_conflicting_email() -> None:
|
||||
first_contact = _contact("contact-1", "account-1", "first@example.com")
|
||||
other_contact = _contact("contact-2", "account-2", "other@example.com")
|
||||
identity = IMIdentity.create(
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
integration_id=_REVISION.integration_id,
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Old Name",
|
||||
email="first@example.com",
|
||||
raw_payload={"source": "previous"},
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=_NOW,
|
||||
now=_NOW,
|
||||
)
|
||||
binding = IMBinding.create(
|
||||
binding_id=IMBindingId("binding-1"),
|
||||
integration_id=_REVISION.integration_id,
|
||||
scope=IMBindingScope.ORGANIZATION,
|
||||
scope_id=str(_REVISION.integration_id),
|
||||
contact_id=first_contact.contact.id,
|
||||
identity_id=identity.id,
|
||||
provider=IMProvider.FEISHU,
|
||||
bound_by_account_id=None,
|
||||
now=_NOW,
|
||||
)
|
||||
entry = ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Current Name",
|
||||
email=" OTHER@EXAMPLE.COM ",
|
||||
raw_payload={"source": "provider"},
|
||||
)
|
||||
snapshot = ReconciliationSnapshot(
|
||||
identities=(identity,),
|
||||
bindings=(binding,),
|
||||
contacts=(first_contact, other_contact),
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=(entry,),
|
||||
snapshot=snapshot,
|
||||
)
|
||||
|
||||
assert len(plan.actions) == 1
|
||||
assert plan.actions[0].match_kind is MatchKind.PROVIDER_USER_ID
|
||||
assert plan.actions[0].identity_id == identity.id
|
||||
assert plan.actions[0].contact_id == first_contact.contact.id
|
||||
assert plan.removed_identity_ids == ()
|
||||
|
||||
|
||||
def test_reconciler_uses_normalized_email_fallback_without_creating_contacts() -> None:
|
||||
contact = _contact("contact-1", "account-1", "reviewer@example.com")
|
||||
entry = ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Reviewer",
|
||||
email=" REVIEWER@EXAMPLE.COM ",
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=(entry,),
|
||||
snapshot=ReconciliationSnapshot(contacts=(contact,)),
|
||||
)
|
||||
|
||||
assert plan.actions[0].match_kind is MatchKind.NORMALIZED_EMAIL
|
||||
assert plan.actions[0].contact_id == contact.contact.id
|
||||
assert plan.actions[0].identity_id is None
|
||||
assert not hasattr(plan, "contacts_to_create")
|
||||
|
||||
|
||||
def test_reconciler_uses_workspace_member_contact_for_email_fallback() -> None:
|
||||
workspace_member = ContactSnapshot(
|
||||
contact=Contact.workspace_member(
|
||||
contact_id=ContactId("contact-workspace-member"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
account_id=AccountId("account-1"),
|
||||
name="Workspace Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
),
|
||||
account_available=True,
|
||||
)
|
||||
entry = ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Reviewer",
|
||||
email=" REVIEWER@EXAMPLE.COM ",
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=(entry,),
|
||||
snapshot=ReconciliationSnapshot(contacts=(workspace_member,)),
|
||||
)
|
||||
|
||||
assert plan.actions[0].match_kind is MatchKind.NORMALIZED_EMAIL
|
||||
assert plan.actions[0].contact_id == workspace_member.contact.id
|
||||
|
||||
|
||||
def test_reconciler_does_not_use_external_contact_for_email_fallback() -> None:
|
||||
external_contact = ContactSnapshot(
|
||||
contact=Contact.external(
|
||||
contact_id=ContactId("contact-external"),
|
||||
workspace_id=WorkspaceId("workspace-1"),
|
||||
name="External Reviewer",
|
||||
email="reviewer@example.com",
|
||||
now=_NOW,
|
||||
),
|
||||
account_available=True,
|
||||
)
|
||||
entry = ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-1",
|
||||
display_name="Reviewer",
|
||||
email="reviewer@example.com",
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=(entry,),
|
||||
snapshot=ReconciliationSnapshot(contacts=(external_contact,)),
|
||||
)
|
||||
|
||||
assert plan.actions[0].match_kind is MatchKind.UNMATCHED
|
||||
assert plan.actions[0].contact_id is None
|
||||
|
||||
|
||||
def test_reconciler_returns_unmatched_for_missing_or_ineligible_contact() -> None:
|
||||
unavailable = _contact("contact-1", "account-1", "reviewer@example.com", available=False)
|
||||
entries = (
|
||||
ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-1", display_name="Reviewer", email="reviewer@example.com", raw_payload={}
|
||||
),
|
||||
ProviderDirectoryEntry.create(
|
||||
provider_user_id="provider-user-2", display_name="No Email", email=None, raw_payload={}
|
||||
),
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=entries,
|
||||
snapshot=ReconciliationSnapshot(contacts=(unavailable,)),
|
||||
)
|
||||
|
||||
assert [action.match_kind for action in plan.actions] == [MatchKind.UNMATCHED, MatchKind.UNMATCHED]
|
||||
assert all(action.contact_id is None for action in plan.actions)
|
||||
|
||||
|
||||
def test_reconciler_marks_missing_current_identities_for_removal() -> None:
|
||||
identity = IMIdentity.create(
|
||||
identity_id=IMIdentityId("identity-1"),
|
||||
integration_id=_REVISION.integration_id,
|
||||
provider=IMProvider.FEISHU,
|
||||
provider_user_id="missing-user",
|
||||
display_name=None,
|
||||
email=None,
|
||||
raw_payload={},
|
||||
last_seen_sync_run_id=None,
|
||||
last_seen_at=_NOW,
|
||||
now=_NOW,
|
||||
)
|
||||
|
||||
plan = SyncReconciler.reconcile(
|
||||
sync_run_id=IMSyncRunId("run-1"),
|
||||
integration_revision=_REVISION,
|
||||
provider=IMProvider.FEISHU,
|
||||
entries=(),
|
||||
snapshot=ReconciliationSnapshot(identities=(identity,)),
|
||||
)
|
||||
|
||||
assert plan.removed_identity_ids == (identity.id,)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Architecture tests for the infrastructure-free Human Input v2 domain."""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.human_input_v2.entities import ContactId as LegacyContactId
|
||||
from core.human_input_v2.shared import ContactId
|
||||
|
||||
_DOMAIN_ROOT = Path(__file__).resolve().parents[4] / "core/human_input_v2"
|
||||
_FORBIDDEN_PREFIXES = (
|
||||
"flask",
|
||||
"controllers",
|
||||
"models",
|
||||
"sqlalchemy",
|
||||
"core.model_runtime",
|
||||
"core.plugin",
|
||||
"dify_plugin",
|
||||
)
|
||||
|
||||
|
||||
def test_domain_packages_have_no_transport_or_persistence_imports() -> None:
|
||||
violations: list[str] = []
|
||||
domain_files = sorted((_DOMAIN_ROOT / "contact_directory").glob("*.py"))
|
||||
domain_files.extend(sorted((_DOMAIN_ROOT / "im_integration").glob("*.py")))
|
||||
approval_files = sorted((_DOMAIN_ROOT / "approval").glob("*.py"))
|
||||
domain_files.extend(approval_files)
|
||||
|
||||
assert approval_files, "the approval domain package must exist"
|
||||
for path in domain_files:
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported_modules = [alias.name for alias in node.names]
|
||||
elif isinstance(node, ast.ImportFrom) and node.module is not None:
|
||||
imported_modules = [node.module]
|
||||
else:
|
||||
continue
|
||||
for imported_module in imported_modules:
|
||||
if imported_module.startswith(_FORBIDDEN_PREFIXES):
|
||||
violations.append(f"{path.name}: {imported_module}")
|
||||
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_approval_package_import_does_not_load_transport_persistence_or_provider_clients() -> None:
|
||||
script = f"""
|
||||
import sys
|
||||
import core.human_input_v2.approval
|
||||
|
||||
forbidden_prefixes = {_FORBIDDEN_PREFIXES!r}
|
||||
violations = sorted(
|
||||
module_name
|
||||
for module_name in sys.modules
|
||||
if any(
|
||||
module_name == prefix or module_name.startswith(f"{{prefix}}.")
|
||||
for prefix in forbidden_prefixes
|
||||
)
|
||||
)
|
||||
if violations:
|
||||
raise SystemExit(f"forbidden modules loaded: {{violations}}")
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=_DOMAIN_ROOT.parents[1],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
env={**os.environ, "PYTHONPATH": str(_DOMAIN_ROOT.parents[1])},
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
|
||||
|
||||
def test_legacy_contact_id_remains_a_string_shaped_transport_identifier() -> None:
|
||||
assert LegacyContactId("contact-1") == "contact-1"
|
||||
assert ContactId("contact-1").to_primitive() == "contact-1"
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for values shared by Human Input v2 bounded contexts."""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core.human_input_v2.shared import (
|
||||
AccountId,
|
||||
ContactId,
|
||||
DeploymentScope,
|
||||
EndUserId,
|
||||
NormalizedEmail,
|
||||
UtcTimestamp,
|
||||
WorkspaceId,
|
||||
WorkspaceScope,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identifier_type", [AccountId, ContactId, EndUserId, WorkspaceId])
|
||||
@pytest.mark.parametrize("invalid_value", ["", " ", "\n"])
|
||||
def test_typed_ids_reject_blank_values(identifier_type, invalid_value: str) -> None:
|
||||
with pytest.raises(ValueError, match="must not be blank"):
|
||||
identifier_type(invalid_value)
|
||||
|
||||
|
||||
def test_typed_ids_are_equal_only_within_the_same_id_type() -> None:
|
||||
assert ContactId("same") == ContactId("same")
|
||||
assert ContactId("same") != AccountId("same")
|
||||
assert ContactId("same").to_primitive() == "same"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "normalized"),
|
||||
[
|
||||
(" USER@Example.COM ", "user@example.com"),
|
||||
("StraSSE@EXAMPLE.COM", "strasse@example.com"),
|
||||
],
|
||||
)
|
||||
def test_normalized_email_canonicalizes_equality(raw: str, normalized: str) -> None:
|
||||
assert NormalizedEmail(raw) == NormalizedEmail(normalized)
|
||||
assert NormalizedEmail(raw).to_primitive() == normalized
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_email", ["", "plain-text", "@example.com", "user@", "a b@example.com"])
|
||||
def test_normalized_email_rejects_invalid_values(invalid_email: str) -> None:
|
||||
with pytest.raises(ValueError, match="valid email"):
|
||||
NormalizedEmail(invalid_email)
|
||||
|
||||
|
||||
def test_owner_scopes_are_explicit_and_immutable() -> None:
|
||||
workspace_id = WorkspaceId("workspace-1")
|
||||
|
||||
assert DeploymentScope().to_primitive() == {"kind": "deployment"}
|
||||
assert WorkspaceScope(workspace_id).to_primitive() == {
|
||||
"kind": "workspace",
|
||||
"workspace_id": "workspace-1",
|
||||
}
|
||||
with pytest.raises(AttributeError):
|
||||
WorkspaceScope(workspace_id).workspace_id = WorkspaceId("workspace-2") # type: ignore[misc]
|
||||
|
||||
|
||||
def test_utc_timestamp_normalizes_and_serializes_at_the_boundary() -> None:
|
||||
source = datetime(2026, 7, 25, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
||||
timestamp = UtcTimestamp(source)
|
||||
|
||||
assert timestamp.value == datetime(2026, 7, 25, 2, 30, tzinfo=UTC)
|
||||
assert timestamp.to_primitive() == "2026-07-25T02:30:00Z"
|
||||
with pytest.raises(TypeError):
|
||||
json.dumps(timestamp)
|
||||
|
||||
|
||||
def test_utc_timestamp_rejects_naive_datetime() -> None:
|
||||
with pytest.raises(ValueError, match="timezone-aware"):
|
||||
UtcTimestamp(datetime(2026, 7, 25, 2, 30))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user