mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 22:02:23 +08:00
417 lines
17 KiB
Python
417 lines
17 KiB
Python
"""Manage persisted trigger subscriptions outside repository transactions."""
|
|
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from contextlib import AbstractContextManager
|
|
from dataclasses import dataclass, replace
|
|
from typing import Any, Protocol, overload
|
|
|
|
from constants import HIDDEN_VALUE, UNKNOWN_VALUE
|
|
from core.entities.provider_entities import BasicProviderConfig
|
|
from core.plugin.entities.plugin_daemon import CredentialType
|
|
from core.trigger.entities.entities import Subscription, UnsubscribeResult
|
|
from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url
|
|
from models.enums import PermissionEnum
|
|
from models.provider_ids import TriggerProviderID
|
|
from services.trigger.subscription_query_service import TriggerSubscriptionSchemas
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TriggerSubscriptionError(Exception):
|
|
"""A trigger subscription operation could not be completed."""
|
|
|
|
|
|
class TriggerSubscriptionNotFoundError(TriggerSubscriptionError):
|
|
"""The subscription does not belong to the requested workspace and provider."""
|
|
|
|
|
|
class TriggerSubscriptionNameConflictError(TriggerSubscriptionError):
|
|
"""The provider already has a subscription with the requested name."""
|
|
|
|
|
|
class TriggerSubscriptionChangedError(TriggerSubscriptionError):
|
|
"""The persisted subscription changed while an operation was being prepared."""
|
|
|
|
|
|
class TriggerSubscriptionLeaseLostError(TriggerSubscriptionChangedError):
|
|
"""The mutation lease was lost before its next side effect or database write."""
|
|
|
|
|
|
class TriggerSubscriptionLimitError(TriggerSubscriptionError):
|
|
"""The provider has reached its subscription limit."""
|
|
|
|
|
|
class TriggerSubscriptionProviderError(TriggerSubscriptionError):
|
|
"""The plugin provider could not complete the requested operation."""
|
|
|
|
|
|
class TriggerSubscriptionInvalidError(TriggerSubscriptionError):
|
|
"""The requested operation or credentials are invalid for this subscription."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TriggerSubscriptionSnapshot:
|
|
id: str
|
|
name: str
|
|
tenant_id: str
|
|
user_id: str
|
|
provider_id: str
|
|
endpoint_id: str
|
|
credential_type: CredentialType
|
|
parameters: Mapping[str, Any]
|
|
properties: Mapping[str, Any]
|
|
credentials: Mapping[str, object]
|
|
credential_expires_at: int
|
|
expires_at: int
|
|
visibility: PermissionEnum
|
|
|
|
|
|
class TriggerSubscriptionStore(Protocol):
|
|
"""Return detached records and complete each write in one bounded transaction."""
|
|
|
|
def get(self, *, tenant_id: str, subscription_id: str) -> TriggerSubscriptionSnapshot | None: ...
|
|
|
|
def create(self, *, subscription: TriggerSubscriptionSnapshot, max_count: int) -> None: ...
|
|
|
|
def update(self, *, expected: TriggerSubscriptionSnapshot, replacement: TriggerSubscriptionSnapshot) -> None: ...
|
|
|
|
def delete(self, *, expected: TriggerSubscriptionSnapshot) -> None: ...
|
|
|
|
|
|
class TriggerSubscriptionLease(Protocol):
|
|
def ensure_owned(self) -> None:
|
|
"""Renew the lease or raise if ownership cannot be confirmed."""
|
|
...
|
|
|
|
|
|
class TriggerSubscriptionLocks(Protocol):
|
|
"""Serialize mutations; release errors do not replace the mutation outcome."""
|
|
|
|
def for_provider(self, *, tenant_id: str, provider_id: TriggerProviderID) -> AbstractContextManager[None]: ...
|
|
|
|
def for_subscription(
|
|
self, *, tenant_id: str, subscription_id: str
|
|
) -> AbstractContextManager[TriggerSubscriptionLease]:
|
|
"""Hold a renewable lease until the provider operation and SQL write finish."""
|
|
...
|
|
|
|
|
|
class TriggerSubscriptionProviderGateway(Protocol):
|
|
def get_schemas(
|
|
self, *, tenant_id: str, provider_id: TriggerProviderID, credential_type: CredentialType
|
|
) -> TriggerSubscriptionSchemas: ...
|
|
|
|
def validate_credentials(
|
|
self, *, tenant_id: str, provider_id: TriggerProviderID, user_id: str, credentials: Mapping[str, Any]
|
|
) -> None: ...
|
|
|
|
def subscribe(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
provider_id: TriggerProviderID,
|
|
user_id: str,
|
|
endpoint: str,
|
|
parameters: Mapping[str, Any],
|
|
credentials: Mapping[str, object],
|
|
credential_type: CredentialType,
|
|
) -> Subscription: ...
|
|
|
|
def unsubscribe(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
provider_id: TriggerProviderID,
|
|
user_id: str,
|
|
subscription: Subscription,
|
|
credentials: Mapping[str, object],
|
|
credential_type: CredentialType,
|
|
) -> UnsubscribeResult: ...
|
|
|
|
|
|
class TriggerSubscriptionCipher(Protocol):
|
|
"""Transform values according to a provider schema without changing their shape."""
|
|
|
|
@overload
|
|
def encrypt(self, data: Mapping[str, str]) -> Mapping[str, str]: ...
|
|
|
|
@overload
|
|
def encrypt(self, data: Mapping[str, object]) -> Mapping[str, object]: ...
|
|
|
|
@overload
|
|
def decrypt(self, data: Mapping[str, str]) -> Mapping[str, str]: ...
|
|
|
|
@overload
|
|
def decrypt(self, data: Mapping[str, object]) -> Mapping[str, object]: ...
|
|
|
|
|
|
class TriggerSubscriptionCodec(Protocol):
|
|
def for_schema(self, *, tenant_id: str, schema: list[BasicProviderConfig]) -> TriggerSubscriptionCipher:
|
|
"""Create a cipher scoped to the workspace and provider schema."""
|
|
...
|
|
|
|
|
|
class TriggerSubscriptionCacheInvalidator(Protocol):
|
|
def __call__(self, *, tenant_id: str, provider_id: str, subscription_id: str) -> None: ...
|
|
|
|
|
|
class TriggerSubscriptionService:
|
|
"""Keep provider calls, credential processing and Redis outside repository transactions.
|
|
|
|
TODO: The shared RSA key provider still owns a scoped DB session for encryption;
|
|
remove that dependency when its tenant key reads return detached values.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
subscriptions: TriggerSubscriptionStore,
|
|
providers: TriggerSubscriptionProviderGateway,
|
|
locks: TriggerSubscriptionLocks,
|
|
codec: TriggerSubscriptionCodec,
|
|
invalidate_cache: TriggerSubscriptionCacheInvalidator,
|
|
) -> None:
|
|
self._subscriptions = subscriptions
|
|
self._providers = providers
|
|
self._locks = locks
|
|
self._codec = codec
|
|
self._invalidate_cache = invalidate_cache
|
|
|
|
def create(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
name: str,
|
|
provider_id: TriggerProviderID,
|
|
endpoint_id: str,
|
|
credential_type: CredentialType,
|
|
parameters: Mapping[str, Any],
|
|
properties: Mapping[str, Any],
|
|
credentials: Mapping[str, object],
|
|
subscription_id: str,
|
|
credential_expires_at: int,
|
|
expires_at: int,
|
|
) -> Mapping[str, str]:
|
|
schemas = self._providers.get_schemas(
|
|
tenant_id=tenant_id, provider_id=provider_id, credential_type=credential_type
|
|
)
|
|
encrypted_credentials = (
|
|
dict(
|
|
self._codec.for_schema(tenant_id=tenant_id, schema=schemas.credentials[credential_type]).encrypt(
|
|
credentials
|
|
)
|
|
)
|
|
if credential_type != CredentialType.UNAUTHORIZED
|
|
else {}
|
|
)
|
|
subscription = TriggerSubscriptionSnapshot(
|
|
id=subscription_id,
|
|
name=name,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=str(provider_id),
|
|
endpoint_id=endpoint_id,
|
|
credential_type=credential_type,
|
|
parameters=dict(parameters),
|
|
properties=dict(self._codec.for_schema(tenant_id=tenant_id, schema=schemas.properties).encrypt(properties)),
|
|
credentials=encrypted_credentials,
|
|
credential_expires_at=credential_expires_at,
|
|
expires_at=expires_at,
|
|
visibility=PermissionEnum.ALL_TEAM,
|
|
)
|
|
with self._locks.for_provider(tenant_id=tenant_id, provider_id=provider_id):
|
|
self._subscriptions.create(subscription=subscription, max_count=10)
|
|
return {"result": "success", "id": subscription_id}
|
|
|
|
def update(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
subscription_id: str,
|
|
name: str | None,
|
|
credentials: Mapping[str, Any] | None,
|
|
parameters: Mapping[str, Any] | None,
|
|
properties: Mapping[str, Any] | None,
|
|
) -> None:
|
|
"""Serialize the remote rebuild and conditional write for an owned subscription.
|
|
|
|
A lease is required through the write; on lease loss no further provider call
|
|
or database write is attempted. Already completed remote effects cannot be undone.
|
|
"""
|
|
# Preserve not-found behavior when Redis is unavailable; reload under the
|
|
# lease so the mutation never relies on this preliminary snapshot.
|
|
self._require(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
with self._locks.for_subscription(tenant_id=tenant_id, subscription_id=subscription_id) as lease:
|
|
snapshot = self._require(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
provider_id = TriggerProviderID(snapshot.provider_id)
|
|
schemas = self._providers.get_schemas(
|
|
tenant_id=tenant_id, provider_id=provider_id, credential_type=snapshot.credential_type
|
|
)
|
|
replacement = replace(snapshot, name=name if name is not None else snapshot.name)
|
|
rename = name is not None and not any((credentials, parameters, properties))
|
|
property_encrypter = self._codec.for_schema(tenant_id=tenant_id, schema=schemas.properties)
|
|
if rename or snapshot.credential_type == CredentialType.UNAUTHORIZED:
|
|
# Manual subscriptions only support name and properties. Empty
|
|
# mappings retain the original rename/partial-update semantics.
|
|
if properties is not None:
|
|
replacement = replace(
|
|
replacement,
|
|
properties=dict(
|
|
property_encrypter.encrypt(
|
|
self._restore_hidden(properties, property_encrypter.decrypt(snapshot.properties))
|
|
)
|
|
),
|
|
)
|
|
else:
|
|
credential_encrypter = self._codec.for_schema(
|
|
tenant_id=tenant_id, schema=schemas.credentials[snapshot.credential_type]
|
|
)
|
|
original_credentials = credential_encrypter.decrypt(snapshot.credentials)
|
|
original_properties = property_encrypter.decrypt(snapshot.properties)
|
|
lease.ensure_owned()
|
|
try:
|
|
removed = self._providers.unsubscribe(
|
|
tenant_id=tenant_id,
|
|
provider_id=provider_id,
|
|
user_id=snapshot.user_id,
|
|
subscription=self._entity(snapshot, original_properties),
|
|
credentials=original_credentials,
|
|
credential_type=snapshot.credential_type,
|
|
)
|
|
except Exception as error:
|
|
raise TriggerSubscriptionProviderError(
|
|
f"Failed to remove trigger subscription {subscription_id} from provider {provider_id}."
|
|
) from error
|
|
if not removed.success:
|
|
raise TriggerSubscriptionProviderError(
|
|
f"Provider {provider_id} rejected removal of trigger subscription {subscription_id}: "
|
|
f"{removed.message}"
|
|
)
|
|
restored_credentials = self._restore_hidden(credentials or original_credentials, original_credentials)
|
|
new_parameters = parameters or snapshot.parameters
|
|
lease.ensure_owned()
|
|
try:
|
|
created = self._providers.subscribe(
|
|
tenant_id=tenant_id,
|
|
provider_id=provider_id,
|
|
user_id=snapshot.user_id,
|
|
endpoint=generate_plugin_trigger_endpoint_url(snapshot.endpoint_id),
|
|
parameters=new_parameters,
|
|
credentials=restored_credentials,
|
|
credential_type=snapshot.credential_type,
|
|
)
|
|
except Exception as error:
|
|
raise TriggerSubscriptionProviderError(
|
|
f"Failed to rebuild trigger subscription {subscription_id} with provider {provider_id}."
|
|
) from error
|
|
replacement = replace(
|
|
replacement,
|
|
credentials=dict(credential_encrypter.encrypt(restored_credentials)),
|
|
parameters=dict(new_parameters),
|
|
properties=dict(
|
|
property_encrypter.encrypt(self._restore_hidden(created.properties, original_properties))
|
|
),
|
|
expires_at=created.expires_at,
|
|
)
|
|
lease.ensure_owned()
|
|
self._subscriptions.update(expected=snapshot, replacement=replacement)
|
|
self._invalidate(snapshot)
|
|
|
|
def delete(self, *, tenant_id: str, subscription_id: str) -> None:
|
|
"""Serialize remote removal and the scoped SQL delete under a renewable lease.
|
|
|
|
On lost ownership, no further SQL write is attempted; a prior remote removal
|
|
cannot be undone. Provider failures retain the existing best-effort behavior.
|
|
"""
|
|
# Preserve not-found behavior when Redis is unavailable; reload under the
|
|
# lease so the mutation never relies on this preliminary snapshot.
|
|
self._require(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
with self._locks.for_subscription(tenant_id=tenant_id, subscription_id=subscription_id) as lease:
|
|
snapshot = self._require(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
provider_id = TriggerProviderID(snapshot.provider_id)
|
|
schemas = self._providers.get_schemas(
|
|
tenant_id=tenant_id, provider_id=provider_id, credential_type=snapshot.credential_type
|
|
)
|
|
if snapshot.credential_type in {CredentialType.API_KEY, CredentialType.OAUTH2}:
|
|
lease.ensure_owned()
|
|
try:
|
|
self._providers.unsubscribe(
|
|
tenant_id=tenant_id,
|
|
provider_id=provider_id,
|
|
user_id=snapshot.user_id,
|
|
subscription=self._entity(snapshot, snapshot.properties),
|
|
credentials=self._codec.for_schema(
|
|
tenant_id=tenant_id, schema=schemas.credentials[snapshot.credential_type]
|
|
).decrypt(snapshot.credentials),
|
|
credential_type=snapshot.credential_type,
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to unsubscribe trigger subscription %s before deletion", subscription_id)
|
|
lease.ensure_owned()
|
|
self._subscriptions.delete(expected=snapshot)
|
|
self._invalidate(snapshot)
|
|
|
|
def verify(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_id: str,
|
|
credentials: Mapping[str, Any],
|
|
) -> Mapping[str, bool]:
|
|
snapshot = self._require(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
if snapshot.provider_id != str(provider_id):
|
|
raise TriggerSubscriptionNotFoundError(f"Trigger subscription {subscription_id} not found.")
|
|
if snapshot.credential_type != CredentialType.API_KEY:
|
|
return {"verified": True}
|
|
schemas = self._providers.get_schemas(
|
|
tenant_id=tenant_id, provider_id=provider_id, credential_type=snapshot.credential_type
|
|
)
|
|
original_credentials = self._codec.for_schema(
|
|
tenant_id=tenant_id, schema=schemas.credentials[snapshot.credential_type]
|
|
).decrypt(snapshot.credentials)
|
|
self._providers.validate_credentials(
|
|
tenant_id=tenant_id,
|
|
provider_id=provider_id,
|
|
user_id=user_id,
|
|
credentials=self._restore_hidden(credentials, original_credentials),
|
|
)
|
|
return {"verified": True}
|
|
|
|
def _require(self, *, tenant_id: str, subscription_id: str) -> TriggerSubscriptionSnapshot:
|
|
snapshot = self._subscriptions.get(tenant_id=tenant_id, subscription_id=subscription_id)
|
|
if snapshot is None:
|
|
raise TriggerSubscriptionNotFoundError(f"Trigger subscription {subscription_id} not found.")
|
|
return snapshot
|
|
|
|
def _invalidate(self, snapshot: TriggerSubscriptionSnapshot) -> None:
|
|
try:
|
|
self._invalidate_cache(
|
|
tenant_id=snapshot.tenant_id, provider_id=snapshot.provider_id, subscription_id=snapshot.id
|
|
)
|
|
except Exception:
|
|
# The write is committed; reporting failure would invite unsafe retries.
|
|
logger.exception(
|
|
"Failed to invalidate cache for trigger subscription %s in workspace %s",
|
|
snapshot.id,
|
|
snapshot.tenant_id,
|
|
)
|
|
|
|
@staticmethod
|
|
def _restore_hidden(values: Mapping[str, Any], original: Mapping[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
key: original.get(key, UNKNOWN_VALUE) if value == HIDDEN_VALUE else value for key, value in values.items()
|
|
}
|
|
|
|
@staticmethod
|
|
def _entity(snapshot: TriggerSubscriptionSnapshot, properties: Mapping[str, Any]) -> Subscription:
|
|
return Subscription(
|
|
expires_at=snapshot.expires_at,
|
|
endpoint=generate_plugin_trigger_endpoint_url(snapshot.endpoint_id),
|
|
parameters=snapshot.parameters,
|
|
properties=properties,
|
|
)
|