mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 05:41:09 +08:00
465 lines
18 KiB
Python
465 lines
18 KiB
Python
import logging
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from contextlib import AbstractContextManager
|
|
from datetime import datetime
|
|
from typing import Any, Protocol
|
|
|
|
from flask import Request, Response
|
|
|
|
from core.plugin.entities.plugin_daemon import CredentialType
|
|
from core.plugin.entities.request import TriggerDispatchResponse
|
|
from core.tools.errors import ToolProviderCredentialValidationError
|
|
from core.trigger.entities.api_entities import SubscriptionBuilderApiEntity
|
|
from core.trigger.entities.entities import (
|
|
RequestLog,
|
|
Subscription,
|
|
SubscriptionBuilder,
|
|
SubscriptionBuilderUpdater,
|
|
SubscriptionConstructor,
|
|
)
|
|
from core.trigger.provider import PluginTriggerProviderController
|
|
from core.trigger.utils.encryption import masked_credentials
|
|
from core.trigger.utils.endpoint import generate_plugin_trigger_endpoint_url
|
|
from models.provider_ids import TriggerProviderID
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TriggerSubscriptionBuilderStore(Protocol):
|
|
"""Persist temporary builders and validation logs without owning their policies."""
|
|
|
|
def get(self, *, subscription_id: str) -> SubscriptionBuilder | None: ...
|
|
|
|
def save(self, *, builder: SubscriptionBuilder) -> None: ...
|
|
|
|
def delete(self, *, subscription_id: str) -> None: ...
|
|
|
|
def lock(self, *, subscription_id: str) -> AbstractContextManager[None]: ...
|
|
|
|
def list_logs(self, *, endpoint_id: str) -> list[RequestLog]: ...
|
|
|
|
def append_log(self, *, log: RequestLog) -> None: ...
|
|
|
|
|
|
class TriggerSubscriptionBuilderQuery(Protocol):
|
|
def get_subscription_builder(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
) -> SubscriptionBuilder | None:
|
|
"""Return the owned builder, or None if its temporary capability expired."""
|
|
...
|
|
|
|
|
|
class TriggerSubscriptionProviderLookup(Protocol):
|
|
def __call__(self, *, tenant_id: str, provider_id: TriggerProviderID) -> PluginTriggerProviderController: ...
|
|
|
|
|
|
class TriggerSubscriber(Protocol):
|
|
def __call__(
|
|
self,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
endpoint: str,
|
|
parameters: Mapping[str, Any],
|
|
credentials: Mapping[str, str],
|
|
credential_type: CredentialType,
|
|
) -> Subscription: ...
|
|
|
|
|
|
class TriggerSubscriptionWriter(Protocol):
|
|
def __call__(
|
|
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, str],
|
|
subscription_id: str,
|
|
credential_expires_at: int,
|
|
expires_at: int,
|
|
) -> Mapping[str, Any]: ...
|
|
|
|
|
|
class TriggerSubscriptionBuilderService:
|
|
"""Manage temporary subscription ownership, updates and provider verification."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
builders: TriggerSubscriptionBuilderStore,
|
|
get_provider: TriggerSubscriptionProviderLookup,
|
|
subscribe: TriggerSubscriber,
|
|
create_subscription: TriggerSubscriptionWriter,
|
|
) -> None:
|
|
self._builders = builders
|
|
self._get_provider = get_provider
|
|
self._subscribe = subscribe
|
|
self._create_subscription = create_subscription
|
|
|
|
def create_trigger_subscription_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
credential_type: CredentialType,
|
|
) -> SubscriptionBuilderApiEntity:
|
|
"""
|
|
Add a new trigger subscription validation.
|
|
"""
|
|
provider_controller = self._get_provider(tenant_id=tenant_id, provider_id=provider_id)
|
|
if not provider_controller:
|
|
raise ValueError(f"Provider {provider_id} not found")
|
|
|
|
subscription_constructor: SubscriptionConstructor | None = provider_controller.get_subscription_constructor()
|
|
subscription_id = str(uuid.uuid4())
|
|
subscription_builder = SubscriptionBuilder(
|
|
id=subscription_id,
|
|
name=None,
|
|
endpoint_id=subscription_id,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=str(provider_id),
|
|
parameters=subscription_constructor.get_default_parameters() if subscription_constructor else {},
|
|
properties=provider_controller.get_subscription_default_properties(),
|
|
credentials={},
|
|
credential_type=credential_type,
|
|
credential_expires_at=-1,
|
|
expires_at=-1,
|
|
)
|
|
self._builders.save(builder=subscription_builder)
|
|
return self.builder_to_api_entity(controller=provider_controller, entity=subscription_builder)
|
|
|
|
def update_trigger_subscription_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
subscription_builder_updater: SubscriptionBuilderUpdater,
|
|
) -> SubscriptionBuilderApiEntity:
|
|
"""
|
|
Update a trigger subscription validation.
|
|
"""
|
|
subscription_id = subscription_builder_id
|
|
provider_controller = self._get_provider(tenant_id=tenant_id, provider_id=provider_id)
|
|
if not provider_controller:
|
|
raise ValueError(f"Provider {provider_id} not found")
|
|
|
|
# Acquire lock to prevent concurrent updates
|
|
with self._builders.lock(subscription_id=subscription_id):
|
|
subscription_builder_cache = self._require_owned_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
|
|
subscription_builder_updater.update(subscription_builder_cache)
|
|
|
|
self._builders.save(builder=subscription_builder_cache)
|
|
return self.builder_to_api_entity(controller=provider_controller, entity=subscription_builder_cache)
|
|
|
|
def update_and_verify_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
subscription_builder_updater: SubscriptionBuilderUpdater,
|
|
) -> Mapping[str, bool]:
|
|
"""
|
|
Atomically update and verify a subscription builder.
|
|
This ensures the verification is done on the exact data that was just updated.
|
|
"""
|
|
subscription_id = subscription_builder_id
|
|
provider_controller = self._get_provider(tenant_id=tenant_id, provider_id=provider_id)
|
|
if not provider_controller:
|
|
raise ValueError(f"Provider {provider_id} not found")
|
|
|
|
# Acquire lock for the entire update + verify operation
|
|
with self._builders.lock(subscription_id=subscription_id):
|
|
subscription_builder_cache = self._require_owned_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
|
|
# Update
|
|
subscription_builder_updater.update(subscription_builder_cache)
|
|
self._builders.save(builder=subscription_builder_cache)
|
|
|
|
# Verify (using the just-updated data)
|
|
if subscription_builder_cache.credential_type == CredentialType.OAUTH2:
|
|
return {"verified": bool(subscription_builder_cache.credentials)}
|
|
|
|
if subscription_builder_cache.credential_type == CredentialType.API_KEY:
|
|
credentials_to_validate = subscription_builder_cache.credentials
|
|
try:
|
|
provider_controller.validate_credentials(user_id, credentials_to_validate)
|
|
except ToolProviderCredentialValidationError as e:
|
|
raise ValueError(f"Invalid credentials: {e}") from e
|
|
return {"verified": True}
|
|
|
|
return {"verified": True}
|
|
|
|
def update_and_build_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
subscription_builder_updater: SubscriptionBuilderUpdater,
|
|
) -> None:
|
|
"""
|
|
Atomically update and build a subscription builder.
|
|
This ensures the build uses the exact data that was just updated.
|
|
"""
|
|
subscription_id = subscription_builder_id
|
|
provider_controller = self._get_provider(tenant_id=tenant_id, provider_id=provider_id)
|
|
if not provider_controller:
|
|
raise ValueError(f"Provider {provider_id} not found")
|
|
|
|
# Acquire lock for the entire update + build operation
|
|
with self._builders.lock(subscription_id=subscription_id):
|
|
subscription_builder = self._require_owned_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
|
|
# Update
|
|
subscription_builder_updater.update(subscription_builder)
|
|
self._builders.save(builder=subscription_builder)
|
|
|
|
if not subscription_builder.name:
|
|
raise ValueError("Subscription builder name is required")
|
|
|
|
# Build
|
|
credential_type = CredentialType.of(subscription_builder.credential_type or CredentialType.UNAUTHORIZED)
|
|
if credential_type == CredentialType.UNAUTHORIZED:
|
|
# manually create
|
|
self._create_subscription(
|
|
subscription_id=subscription_builder.id,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
name=subscription_builder.name,
|
|
provider_id=provider_id,
|
|
endpoint_id=subscription_builder.endpoint_id,
|
|
parameters=subscription_builder.parameters,
|
|
properties=subscription_builder.properties,
|
|
credential_expires_at=subscription_builder.credential_expires_at or -1,
|
|
expires_at=subscription_builder.expires_at,
|
|
credentials=subscription_builder.credentials,
|
|
credential_type=credential_type,
|
|
)
|
|
else:
|
|
# automatically create
|
|
subscription: Subscription = self._subscribe(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
endpoint=generate_plugin_trigger_endpoint_url(subscription_builder.endpoint_id),
|
|
parameters=subscription_builder.parameters,
|
|
credentials=subscription_builder.credentials,
|
|
credential_type=credential_type,
|
|
)
|
|
|
|
self._create_subscription(
|
|
subscription_id=subscription_builder.id,
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
name=subscription_builder.name,
|
|
provider_id=provider_id,
|
|
endpoint_id=subscription_builder.endpoint_id,
|
|
parameters=subscription_builder.parameters,
|
|
properties=subscription.properties,
|
|
credentials=subscription_builder.credentials,
|
|
credential_type=credential_type,
|
|
credential_expires_at=subscription_builder.credential_expires_at or -1,
|
|
expires_at=(
|
|
subscription.expires_at
|
|
if subscription.expires_at is not None
|
|
else (subscription_builder.expires_at or -1)
|
|
),
|
|
)
|
|
|
|
# Delete the builder after successful subscription creation
|
|
self._builders.delete(subscription_id=subscription_id)
|
|
|
|
@staticmethod
|
|
def builder_to_api_entity(
|
|
controller: PluginTriggerProviderController, entity: SubscriptionBuilder
|
|
) -> SubscriptionBuilderApiEntity:
|
|
credential_type = CredentialType.of(entity.credential_type or CredentialType.UNAUTHORIZED.value)
|
|
return SubscriptionBuilderApiEntity(
|
|
id=entity.id,
|
|
name=entity.name or "",
|
|
provider=entity.provider_id,
|
|
endpoint=generate_plugin_trigger_endpoint_url(entity.endpoint_id),
|
|
parameters=entity.parameters,
|
|
properties=entity.properties,
|
|
credential_type=credential_type,
|
|
credentials=masked_credentials(
|
|
schemas=controller.get_credentials_schema(credential_type),
|
|
credentials=entity.credentials,
|
|
)
|
|
if controller.get_subscription_constructor()
|
|
else {},
|
|
)
|
|
|
|
def _get_subscription_builder_by_endpoint_id(self, endpoint_id: str) -> SubscriptionBuilder | None:
|
|
"""Resolve the public validation capability without authenticated owner context."""
|
|
subscription_builder = self._builders.get(subscription_id=endpoint_id)
|
|
if subscription_builder is not None and subscription_builder.endpoint_id == endpoint_id:
|
|
return subscription_builder
|
|
|
|
return None
|
|
|
|
def get_subscription_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
) -> SubscriptionBuilder | None:
|
|
"""Return an owned temporary builder, or None when no temporary builder exists."""
|
|
subscription_builder = self._get_subscription_builder_by_endpoint_id(subscription_builder_id)
|
|
if subscription_builder is None:
|
|
return None
|
|
if (
|
|
subscription_builder.id != subscription_builder_id
|
|
or subscription_builder.tenant_id != tenant_id
|
|
or subscription_builder.user_id != user_id
|
|
or subscription_builder.provider_id != str(provider_id)
|
|
):
|
|
raise ValueError(f"Subscription builder {subscription_builder_id} not found")
|
|
return subscription_builder
|
|
|
|
def _require_owned_subscription_builder(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
) -> SubscriptionBuilder:
|
|
"""Return an owned temporary builder or reject an absent capability."""
|
|
subscription_builder = self.get_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
if subscription_builder is None:
|
|
raise ValueError(f"Subscription builder {subscription_builder_id} not found")
|
|
return subscription_builder
|
|
|
|
def append_log(self, endpoint_id: str, request: Request, response: Response) -> None:
|
|
"""Record the validation request and its response."""
|
|
log = RequestLog(
|
|
id=str(uuid.uuid4()),
|
|
endpoint=endpoint_id,
|
|
request={
|
|
"method": request.method,
|
|
"url": request.url,
|
|
"headers": dict(request.headers),
|
|
"data": request.get_data(as_text=True),
|
|
},
|
|
response={
|
|
"status_code": response.status_code,
|
|
"headers": dict(response.headers),
|
|
"data": response.get_data(as_text=True),
|
|
},
|
|
created_at=datetime.now(),
|
|
)
|
|
|
|
self._builders.append_log(log=log)
|
|
|
|
def list_logs(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
) -> list[RequestLog]:
|
|
"""List request logs for validation endpoint."""
|
|
subscription_builder = self._require_owned_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
|
|
return self._builders.list_logs(endpoint_id=subscription_builder.endpoint_id)
|
|
|
|
def process_builder_validation_endpoint(self, endpoint_id: str, request: Request) -> Response | None:
|
|
"""
|
|
Process a temporary endpoint request.
|
|
|
|
:param endpoint_id: The endpoint identifier
|
|
:param request: The Flask request object
|
|
:return: The Flask response object
|
|
"""
|
|
# check if validation endpoint exists
|
|
subscription_builder: SubscriptionBuilder | None = self._get_subscription_builder_by_endpoint_id(endpoint_id)
|
|
if not subscription_builder:
|
|
return None
|
|
|
|
try:
|
|
# response to validation endpoint
|
|
controller: PluginTriggerProviderController = self._get_provider(
|
|
tenant_id=subscription_builder.tenant_id,
|
|
provider_id=TriggerProviderID(subscription_builder.provider_id),
|
|
)
|
|
dispatch_response: TriggerDispatchResponse = controller.dispatch(
|
|
request=request,
|
|
subscription=subscription_builder.to_subscription(),
|
|
credentials={},
|
|
credential_type=CredentialType.UNAUTHORIZED,
|
|
)
|
|
response: Response = dispatch_response.response
|
|
# append the request log
|
|
self.append_log(
|
|
endpoint_id=endpoint_id,
|
|
request=request,
|
|
response=response,
|
|
)
|
|
return response
|
|
except Exception:
|
|
logger.exception("Error during validation endpoint dispatch for endpoint_id=%s", endpoint_id)
|
|
error_response = Response(status=500, response="An internal error has occurred.")
|
|
self.append_log(endpoint_id=endpoint_id, request=request, response=error_response)
|
|
return error_response
|
|
|
|
def get_subscription_builder_by_id(
|
|
self,
|
|
tenant_id: str,
|
|
user_id: str,
|
|
provider_id: TriggerProviderID,
|
|
subscription_builder_id: str,
|
|
) -> SubscriptionBuilderApiEntity:
|
|
"""Get a trigger subscription builder API entity."""
|
|
subscription_builder = self._require_owned_subscription_builder(
|
|
tenant_id=tenant_id,
|
|
user_id=user_id,
|
|
provider_id=provider_id,
|
|
subscription_builder_id=subscription_builder_id,
|
|
)
|
|
return self.builder_to_api_entity(
|
|
controller=self._get_provider(tenant_id=tenant_id, provider_id=provider_id),
|
|
entity=subscription_builder,
|
|
)
|