Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions api/.importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,34 @@ forbidden_modules =
sqlalchemy
werkzeug

[importlinter:contract:consumer-service-boundary]
name = Account consumer use cases are framework and persistence neutral
type = forbidden
source_modules =
services.consumer_service
forbidden_modules =
controllers
extensions
repositories
models
configs
tasks
flask
sqlalchemy
werkzeug
allow_indirect_imports = True

[importlinter:contract:consumer-transport-boundary]
name = Account consumer transport uses application services
type = forbidden
source_modules =
controllers.web.me
forbidden_modules =
repositories
models
sqlalchemy
allow_indirect_imports = True

[importlinter:contract:machinery-framework-boundary]
name = API machinery is framework neutral
type = forbidden
Expand Down
4 changes: 4 additions & 0 deletions api/controllers/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
human_input_file_upload,
human_input_form,
login,
me,
message,
migration,
passport,
Expand All @@ -84,6 +85,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
human_input_file_upload,
human_input_form,
login,
me,
message,
migration,
passport,
Expand All @@ -109,6 +111,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
human_input_file_upload,
human_input_form,
login,
me,
message,
migration,
passport,
Expand Down Expand Up @@ -137,6 +140,7 @@ def _register_controllers() -> tuple[ModuleType, ...]:
"human_input_file_upload",
"human_input_form",
"login",
"me",
"message",
"passport",
"remote_files",
Expand Down
216 changes: 216 additions & 0 deletions api/controllers/web/me.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
"""Account consumer APIs authenticated by the WebApp login cookie."""

from collections.abc import Generator
from contextlib import contextmanager
from datetime import datetime
from urllib.parse import urlsplit
from uuid import UUID

from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_serializer
from werkzeug.exceptions import Forbidden, NotFound, ServiceUnavailable, Unauthorized

from configs import dify_config
from controllers.common.end_user_connections import (
ConnectionConflictHTTPError,
ConnectionNotFoundHTTPError,
raise_connection_error,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models
from controllers.web import web_ns
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.datetime_utils import to_utc_timestamp
from libs.external_api import error_responses
from libs.helper import dump_response
from libs.token import extract_webapp_cookie_token
from services.consumer_service import (
ConsumerApp,
ConsumerAppNotFoundError,
ConsumerAppsQuery,
ConsumerConnection,
ConsumerIdentity,
)
from services.end_user_connections.contracts import ListConnectionsInput
from services.end_user_connections.errors import ConnectionError
from services.web_identity_service import WebIdentityError
from services.web_passport_service import WebPassportUnauthorizedError
from services.webapp_access_query_service import WebAppAccessUnavailableError


def _origin(url: str) -> tuple[str, str, int]:
parsed = urlsplit(url)
if (
parsed.scheme not in {"http", "https"}
or parsed.hostname is None
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("Invalid origin.")
port = parsed.port
return parsed.scheme, parsed.hostname, port if port is not None else (443 if parsed.scheme == "https" else 80)


def _identity() -> ConsumerIdentity:
"""Cookie authentication requires a trusted browser origin for account-wide mutations."""
origin = request.headers.get("Origin")
if origin is not None or request.method not in {"GET", "HEAD", "OPTIONS"}:
try:
trusted = next(
url
for url in (dify_config.APP_WEB_URL, dify_config.CONSOLE_WEB_URL, request.host_url)
if urlsplit(url).netloc
)
if origin is None or _origin(origin) != _origin(trusted):
raise ValueError("Untrusted origin.")
except ValueError:
raise Forbidden("WebApp request origin is not allowed.") from None
try:
return application_services().consumer.authenticate(extract_webapp_cookie_token(request))
except (WebIdentityError, WebPassportUnauthorizedError):
raise Unauthorized("WebApp Account login required.") from None


@contextmanager
def _errors() -> Generator[None]:
try:
yield
except ValidationError:
raise ValueError("Invalid consumer request.") from None
except ConsumerAppNotFoundError:
raise NotFound("App not found.") from None
except WebAppAccessUnavailableError:
raise ServiceUnavailable("App access is temporarily unavailable.") from None
except ConnectionError as error:
raise_connection_error(error)


class ConsumerAppResponse(ConsumerApp, ResponseModel):
@field_serializer("last_used_at")
def serialize_usage(self, value: datetime | None) -> int | None:
return to_utc_timestamp(value) if value is not None else None


class ConsumerAppsResponse(ResponseModel):
data: list[ConsumerAppResponse]
has_more: bool
next_cursor: str | None


class ConsumerConnectionResponse(ConsumerConnection, ResponseModel):
pass


class ConsumerConnectionsResponse(ResponseModel):
data: list[ConsumerConnectionResponse]
has_more: bool
next_cursor: str | None


class ConsumerConnectionsQuery(ListConnectionsInput):
limit: int = Field(default=20, ge=1, le=100, strict=False)


class ConsumerConnectionAppsQuery(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)

cursor: UUID | None = None
limit: int = Field(default=20, ge=1, le=100)


register_response_schema_models(
web_ns,
ConsumerAppResponse,
ConsumerAppsResponse,
ConsumerConnectionResponse,
ConsumerConnectionsResponse,
)


@web_ns.route("/me/apps")
class ConsumerAppsApi(Resource):
@web_ns.doc(
description="List accessible Chatflow and Workflow Apps using the WebApp Account login cookie. "
"Recent use is derived from retained published execution logs. "
"No App Passport or workspace membership is required."
)
@web_ns.doc(params=query_params_from_model(ConsumerAppsQuery))
@web_ns.response(200, "Accessible personal Apps", web_ns.models[ConsumerAppsResponse.__name__])
def get(self):
identity = _identity()
with _errors():
query = ConsumerAppsQuery.model_validate(request.args.to_dict(flat=True))
result = application_services().consumer.list_apps(identity, query)
return dump_response(ConsumerAppsResponse, result)


@web_ns.route("/me/apps/<uuid:app_id>/favorite")
class ConsumerFavoriteApi(Resource):
@web_ns.response(204, "App favorited")
def put(self, app_id: UUID):
identity = _identity()
with _errors():
application_services().consumer.set_favorite(identity, str(app_id), enabled=True)
return "", 204

@web_ns.response(204, "App unfavorited")
def delete(self, app_id: UUID):
identity = _identity()
with _errors():
application_services().consumer.set_favorite(identity, str(app_id), enabled=False)
return "", 204


@web_ns.route("/me/connections")
class ConsumerConnectionsApi(Resource):
@web_ns.doc(
description="List personal Connections grouped by Provider, account label and Connection type. "
"Connections without an account label form individual groups. WebApp counts include only accessible Apps."
)
@web_ns.doc(params=query_params_from_model(ConsumerConnectionsQuery))
@web_ns.response(200, "Account Connections", web_ns.models[ConsumerConnectionsResponse.__name__])
def get(self):
identity = _identity()
with _errors():
query = ConsumerConnectionsQuery.model_validate(request.args.to_dict(flat=True))
result = application_services().consumer.list_connections(
identity,
provider=query.provider,
cursor=query.cursor,
limit=query.limit,
)
return dump_response(ConsumerConnectionsResponse, result)


@web_ns.route("/me/connections/<uuid:connection_key>/apps")
class ConsumerConnectionAppsApi(Resource):
@web_ns.doc(description="List accessible WebApps associated with a Connection group returned by /me/connections.")
@web_ns.doc(params=query_params_from_model(ConsumerConnectionAppsQuery))
@web_ns.response(200, "Associated WebApps", web_ns.models[ConsumerAppsResponse.__name__])
@error_responses(ConnectionNotFoundHTTPError)
def get(self, connection_key: UUID):
identity = _identity()
with _errors():
query = ConsumerConnectionAppsQuery.model_validate(request.args.to_dict(flat=True))
result = application_services().consumer.list_connection_apps(
identity, connection_key, cursor=str(query.cursor) if query.cursor else None, limit=query.limit
)
return dump_response(ConsumerAppsResponse, result)


@web_ns.route("/me/connections/<uuid:connection_key>")
class ConsumerConnectionApi(Resource):
@web_ns.response(204, "Connection group disconnected")
@web_ns.doc(
description="Disconnect all owned Connections matching a group key returned by /me/connections. "
"Cookie-authenticated browser mutations require the configured WebApp Origin."
)
@error_responses(ConnectionNotFoundHTTPError, ConnectionConflictHTTPError)
def delete(self, connection_key: UUID):
identity = _identity()
with _errors():
application_services().consumer.delete_connection(identity, connection_key)
return "", 204
31 changes: 31 additions & 0 deletions api/extensions/application_services/consumer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Compose Account consumer operations using existing App and Connection owners."""

from sqlalchemy.orm import Session, sessionmaker

from repositories.app.console_repository import ConsoleAppRepository
from repositories.end_user_connection_repository import EndUserConnectionRepository
from services.consumer_adapters import SharedConsumerAppDiscovery
from services.consumer_service import ConsumerAppAccess, ConsumerService
from services.end_user_connections.management import ConnectionService
from services.web_identity_service import WebIdentityService
from services.web_passport_service import WebPassportTokenGateway


def build_consumer_service(
*,
database_client: sessionmaker[Session],
access: ConsumerAppAccess,
shared_apps_enabled: bool,
management: ConnectionService,
identities: WebIdentityService,
tokens: WebPassportTokenGateway,
) -> ConsumerService:
return ConsumerService(
apps=ConsoleAppRepository(session_factory=database_client),
connections=EndUserConnectionRepository(session_factory=database_client),
access=access,
shared_apps=SharedConsumerAppDiscovery(enabled=shared_apps_enabled),
management=management,
identities=identities,
tokens=tokens,
)
27 changes: 20 additions & 7 deletions api/extensions/ext_application_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from extensions.application_services.account import AccountServices, build_account_services
from extensions.application_services.agent import AgentAppServices, build_agent_app_services
from extensions.application_services.app import AppServices, build_app_api_key_service, build_app_services
from extensions.application_services.consumer import build_consumer_service
from extensions.application_services.data_sources import (
DataSourceServices,
build_data_source_credentials,
Expand Down Expand Up @@ -136,6 +137,7 @@
from services.billing_portal_service import BillingPortalService
from services.billing_service import BillingService
from services.compliance_download_service import ComplianceDownloadService
from services.consumer_service import ConsumerService
from services.conversation_variable_query import ConversationVariableQuery
from services.credentials.query import CredentialQuery
from services.end_user_connection_adapters import PluginApiKeyCredentialGateway
Expand Down Expand Up @@ -292,6 +294,7 @@ class AppScopedEndUserServices:

@dataclass(frozen=True, slots=True)
class ApplicationServices:
consumer: ConsumerService
rbac: RBACServices
agent_apps: AgentAppServices
advanced_prompt_templates: AdvancedPromptTemplateService
Expand Down Expand Up @@ -596,6 +599,7 @@ def build_application_services(
invitation_tokens=invitation_tokens,
)
end_user_connections = build_end_user_connections(database_client=database_client)
end_user_connection_oauth = build_end_user_connection_oauth(database_client=database_client)
end_user_connection_preflight = build_end_user_connection_preflight(
database_client=database_client, connections=end_user_connections
)
Expand All @@ -612,7 +616,21 @@ def build_application_services(
now=lambda: datetime.now(UTC),
access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES,
)
installed_apps = build_installed_app_services(
database_client=database_client,
webapp_access=webapp_access,
get_workspace_role=workspace_repository.get_account_role,
webapp_auth_enabled=webapp_auth_enabled,
)
return ApplicationServices(
consumer=build_consumer_service(
database_client=database_client,
access=installed_apps.access,
shared_apps_enabled=webapp_auth_enabled,
management=end_user_connections,
identities=web_identity,
tokens=web_passport_tokens,
),
rbac=rbac,
accounts=account_services,
apps=apps,
Expand Down Expand Up @@ -702,12 +720,7 @@ def build_application_services(
audit=LoggingWebAuthenticationAuditGateway(logger=logging.getLogger("controllers.web.login")),
private_app_access_enabled=deployment_edition == DeploymentEdition.ENTERPRISE,
),
installed_apps=build_installed_app_services(
database_client=database_client,
webapp_access=webapp_access,
get_workspace_role=workspace_repository.get_account_role,
webapp_auth_enabled=webapp_auth_enabled,
),
installed_apps=installed_apps,
web_app_runtime=WebAppRuntimeQueryService(
runtime=app_definition_repository,
file_service=file_service,
Expand Down Expand Up @@ -844,7 +857,7 @@ def build_application_services(
),
end_user_connections=end_user_connections,
end_user_connection_preflight=end_user_connection_preflight,
end_user_connection_oauth=build_end_user_connection_oauth(database_client=database_client),
end_user_connection_oauth=end_user_connection_oauth,
web_passport=web_passport,
api_based_extensions=APIBasedExtensionApplicationService(
extensions=APIBasedExtensionRepository(session_factory=database_client),
Expand Down
Loading