From 88406638f2cfe362fe12323f77eace84b6ff9803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E7=8E=AE=20=28Jade=20Lin=29?= Date: Fri, 30 Oct 2026 18:03:48 +0800 Subject: [PATCH 1/8] feat(api): add Account consumer APIs --- api/.importlinter | 28 ++ .../common/end_user_connection_oauth.py | 8 +- .../common/end_user_connections.py | 34 +- api/controllers/console/__init__.py | 1 + api/controllers/console/me.py | 325 +++++++++++++ api/controllers/web/connections.py | 30 +- .../application_services/consumer.py | 34 ++ api/extensions/ext_application_services.py | 28 +- ...1600-b73e9c40a215_add_account_app_usage.py | 36 ++ api/models/__init__.py | 2 + api/models/consumer.py | 22 + api/openapi/markdown/console-openapi.md | 366 +++++++++++++++ api/openapi/markdown/web-openapi.md | 12 +- api/repositories/app/console_repository.py | 206 ++++++++- .../end_user_connection_repository.py | 153 +++++- api/services/app_generate_service.py | 18 +- api/services/consumer_adapters.py | 36 ++ api/services/consumer_service.py | 271 +++++++++++ api/services/end_user_connection_adapters.py | 10 +- .../end_user_connection_oauth_adapters.py | 44 +- .../end_user_connection_revision_adapters.py | 52 ++- .../end_user_connections/contracts.py | 3 +- api/services/end_user_connections/entities.py | 32 ++ .../end_user_connections/management.py | 39 +- api/services/end_user_connections/oauth.py | 27 +- .../end_user_connections/oauth_lifecycle.py | 27 +- api/services/end_user_connections/policy.py | 7 +- api/services/end_user_connections/revision.py | 12 +- api/tasks/remove_app_and_related_data_task.py | 12 + api/tests/helpers/consumer.py | 77 ++++ .../unit_tests/controllers/console/test_me.py | 140 ++++++ ...1600-b73e9c40a215_add_account_app_usage.py | 41 ++ .../services/test_app_generate_service.py | 67 ++- .../services/test_consumer_adapters.py | 39 ++ .../services/test_consumer_service.py | 249 ++++++++++ .../generated/api/console/errors.gen.ts | 6 + .../generated/api/console/me/errors.gen.ts | 132 ++++++ .../generated/api/console/me/orpc.gen.ts | 260 +++++++++++ .../generated/api/console/me/types.gen.ts | 435 ++++++++++++++++++ .../generated/api/console/me/zod.gen.ts | 424 +++++++++++++++++ .../generated/api/console/orpc.gen.ts | 1 + .../generated/api/console/router.gen.ts | 2 + 42 files changed, 3631 insertions(+), 117 deletions(-) create mode 100644 api/controllers/console/me.py create mode 100644 api/extensions/application_services/consumer.py create mode 100644 api/migrations/versions/2026_10_30_1600-b73e9c40a215_add_account_app_usage.py create mode 100644 api/models/consumer.py create mode 100644 api/services/consumer_adapters.py create mode 100644 api/services/consumer_service.py create mode 100644 api/tests/helpers/consumer.py create mode 100644 api/tests/unit_tests/controllers/console/test_me.py create mode 100644 api/tests/unit_tests/migrations/test_2026_10_30_1600-b73e9c40a215_add_account_app_usage.py create mode 100644 api/tests/unit_tests/services/test_consumer_adapters.py create mode 100644 api/tests/unit_tests/services/test_consumer_service.py create mode 100644 packages/contracts/generated/api/console/me/errors.gen.ts create mode 100644 packages/contracts/generated/api/console/me/orpc.gen.ts create mode 100644 packages/contracts/generated/api/console/me/types.gen.ts create mode 100644 packages/contracts/generated/api/console/me/zod.gen.ts diff --git a/api/.importlinter b/api/.importlinter index d74bd46d533474..47478821d5ef65 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -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.console.me +forbidden_modules = + repositories + models + sqlalchemy +allow_indirect_imports = True + [importlinter:contract:machinery-framework-boundary] name = API machinery is framework neutral type = forbidden diff --git a/api/controllers/common/end_user_connection_oauth.py b/api/controllers/common/end_user_connection_oauth.py index fcc221ddbcabae..c7f712c68294b0 100644 --- a/api/controllers/common/end_user_connection_oauth.py +++ b/api/controllers/common/end_user_connection_oauth.py @@ -6,6 +6,7 @@ from configs import dify_config from controllers.common.end_user_connections import raise_connection_error from extensions.ext_application_services import application_services +from services.end_user_connections.entities import AccountConnectionContext from services.end_user_connections.errors import ConnectionError @@ -34,7 +35,12 @@ def complete_end_user_connection_oauth(provider: str, context_id: str) -> Respon connection = service.complete_oauth(attempt, query_string=request.query_string) except ConnectionError as error: raise_connection_error(error) - response = redirect(f"{dify_config.APP_WEB_URL}/oauth-callback?connection_id={connection.id}") + origin = ( + dify_config.CONSOLE_WEB_URL + if isinstance(attempt.context, AccountConnectionContext) + else dify_config.APP_WEB_URL + ) + response = redirect(f"{origin}/oauth-callback?connection_id={connection.id}") response.delete_cookie("context_id", httponly=True, samesite="Lax", secure=request.is_secure) response.headers["Cache-Control"] = "no-store" response.headers["Referrer-Policy"] = "no-referrer" diff --git a/api/controllers/common/end_user_connections.py b/api/controllers/common/end_user_connections.py index a02aaa58cfbf53..0ae95d610ea645 100644 --- a/api/controllers/common/end_user_connections.py +++ b/api/controllers/common/end_user_connections.py @@ -1,11 +1,13 @@ """HTTP failures shared by personal Connection management and the OAuth callback.""" -from typing import NoReturn +from typing import Any, NoReturn -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field, JsonValue +from pydantic.json_schema import SkipJsonSchema from werkzeug.exceptions import Unauthorized from libs.exception import BaseHTTPException +from services.end_user_connections.contracts import UpdateConnectionInput from services.end_user_connections.errors import ( ConnectionAccountRequiredError, ConnectionCheckUnavailableError, @@ -24,6 +26,34 @@ from services.end_user_connections.revision import ConnectionRevision +def _omit_schema_default(schema: dict[str, Any]) -> None: + schema.pop("default", None) + + +class ConnectionUpdatePayload(UpdateConnectionInput): + model_config = ConfigDict( + json_schema_extra={"minProperties": 1, "dependentRequired": {"credentials": ["connection_revision"]}} + ) + + connection_revision: ConnectionRevision | SkipJsonSchema[None] = Field( + default=None, + json_schema_extra=_omit_schema_default, + description="Provider revision required when replacing credentials.", + ) + name: str | SkipJsonSchema[None] = Field( + default=None, + min_length=1, + max_length=100, + json_schema_extra=_omit_schema_default, + ) + credentials: dict[str, JsonValue] | SkipJsonSchema[None] = Field( + default=None, + min_length=1, + repr=False, + json_schema_extra=_omit_schema_default, + ) + + class ConnectionLoginRequiredError(BaseHTTPException): code = 401 error_code = "end_user_login_required" diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 1ea93ae9999b24..37249e47829387 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -143,6 +143,7 @@ def _register_controllers() -> tuple[ModuleType, ...]: feature, human_input_form, init_validate, + me, # noqa: F401 notification, onboarding, resource_access_token, diff --git a/api/controllers/console/me.py b/api/controllers/console/me.py new file mode 100644 index 00000000000000..216b4498e12165 --- /dev/null +++ b/api/controllers/console/me.py @@ -0,0 +1,325 @@ +"""Account consumer APIs; admission never requires or provisions a workspace.""" + +from collections.abc import Generator +from contextlib import contextmanager +from datetime import datetime +from uuid import UUID + +from flask import make_response, request +from flask_restx import Resource +from pydantic import Field, RootModel, ValidationError, field_serializer +from werkzeug.exceptions import NotFound, ServiceUnavailable, Unauthorized + +from controllers.common.end_user_connection_oauth import complete_end_user_connection_oauth +from controllers.common.end_user_connections import ( + ConnectionCheckUnavailableHTTPError, + ConnectionConfigurationHTTPError, + ConnectionConflictHTTPError, + ConnectionCredentialsHTTPError, + ConnectionNotFoundHTTPError, + ConnectionOAuthHTTPError, + ConnectionReauthorizationHTTPError, + ConnectionRevisionChangedHTTPError, + ConnectionSecretUnavailableHTTPError, + ConnectionUpdatePayload, + raise_connection_error, +) +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.console import console_ns +from core.entities.provider_entities import ProviderConfig +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 check_csrf_token, extract_console_cookie_token +from services.consumer_service import ConsumerApp, ConsumerAppNotFoundError, ConsumerAppsQuery, ConsumerIdentity +from services.end_user_connections.contracts import ( + ConnectionContractInput, + ConnectionRevisionView, + ConnectionSummary, + ListConnectionsInput, +) +from services.end_user_connections.errors import ConnectionError +from services.plugin.oauth_service import OAuthProxyService +from services.web_identity_service import WebIdentityError +from services.web_passport_service import WebPassportUnauthorizedError +from services.webapp_access_query_service import WebAppAccessUnavailableError + + +def _identity() -> ConsumerIdentity: + try: + identity = application_services().consumer.authenticate(extract_console_cookie_token(request)) + except (WebIdentityError, WebPassportUnauthorizedError): + raise Unauthorized("Account login required.") from None + check_csrf_token(request, identity.account_id) + return identity + + +@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(ConnectionSummary, ResponseModel): + @field_serializer("created_at", "updated_at", "last_active_at") + def serialize_time(self, value: datetime) -> int: + return to_utc_timestamp(value) + + @field_serializer("expires_at") + def serialize_expiration(self, value: datetime | None) -> int | None: + return to_utc_timestamp(value) if value is not None else None + + +class ConsumerConnectionDetailResponse(ResponseModel): + connection: ConsumerConnectionResponse + apps: list[ConsumerAppResponse] + + +class ConsumerConnectionsResponse(ResponseModel): + data: list[ConsumerConnectionDetailResponse] + has_more: bool + next_cursor: str | None + + +class ConsumerConnectionsQuery(ListConnectionsInput): + limit: int = Field(default=20, ge=1, le=100, strict=False) + + +class ConsumerConnectionRevisionResponse(ConnectionRevisionView, ResponseModel): + pass + + +class ConsumerConnectionUpdatePayload(ConnectionUpdatePayload): + pass + + +class ConsumerConnectionRevisionQuery(ConnectionContractInput): + pass + + +class ConsumerReauthorizePayload(ConnectionContractInput): + pass + + +class ConsumerCredentialSchemaResponse(RootModel[list[ProviderConfig]]): + pass + + +class ConsumerOAuthResponse(ResponseModel): + authorization_url: str + + +register_schema_models( + console_ns, + ConsumerConnectionUpdatePayload, + ConsumerReauthorizePayload, +) +register_response_schema_models( + console_ns, + ConsumerAppResponse, + ConsumerAppsResponse, + ConsumerConnectionResponse, + ConsumerConnectionDetailResponse, + ConsumerConnectionsResponse, + ConsumerConnectionRevisionResponse, + ConsumerCredentialSchemaResponse, + ConsumerOAuthResponse, +) + + +@console_ns.route("/me/apps") +class ConsumerAppsApi(Resource): + @console_ns.doc(params=query_params_from_model(ConsumerAppsQuery)) + @console_ns.response(200, "Accessible personal Apps", console_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) + + +@console_ns.route("/me/apps//favorite") +class ConsumerFavoriteApi(Resource): + @console_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 + + @console_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 + + +@console_ns.route("/me/connections") +class ConsumerConnectionsApi(Resource): + @console_ns.doc(params=query_params_from_model(ConsumerConnectionsQuery)) + @console_ns.response(200, "Account Connections", console_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) + + +@console_ns.route("/me/connections/") +class ConsumerConnectionApi(Resource): + @console_ns.response(200, "Owned Connection", console_ns.models[ConsumerConnectionDetailResponse.__name__]) + @error_responses(ConnectionNotFoundHTTPError) + def get(self, connection_id: UUID): + identity = _identity() + with _errors(): + result = application_services().consumer.get_connection(identity, str(connection_id)) + return dump_response(ConsumerConnectionDetailResponse, result) + + @console_ns.expect(console_ns.models[ConsumerConnectionUpdatePayload.__name__]) + @console_ns.response(200, "Connection updated", console_ns.models[ConsumerConnectionResponse.__name__]) + @error_responses( + ConnectionNotFoundHTTPError, + ConnectionConfigurationHTTPError, + ConnectionConflictHTTPError, + ConnectionCredentialsHTTPError, + ConnectionRevisionChangedHTTPError, + ConnectionCheckUnavailableHTTPError, + ConnectionSecretUnavailableHTTPError, + ) + def patch(self, connection_id: UUID): + identity = _identity() + with _errors(): + command = ConsumerConnectionUpdatePayload.model_validate(console_ns.payload or {}) + result = application_services().consumer.update_connection(identity, str(connection_id), command) + return dump_response(ConsumerConnectionResponse, result) + + @console_ns.response(204, "Connection disconnected") + @error_responses(ConnectionNotFoundHTTPError, ConnectionConflictHTTPError) + def delete(self, connection_id: UUID): + identity = _identity() + with _errors(): + application_services().consumer.delete_connection(identity, str(connection_id)) + return "", 204 + + +@console_ns.route("/me/connections//revision") +class ConsumerConnectionRevisionApi(Resource): + @console_ns.response( + 200, "Current Provider revision", console_ns.models[ConsumerConnectionRevisionResponse.__name__] + ) + @error_responses(ConnectionNotFoundHTTPError, ConnectionConfigurationHTTPError, ConnectionCheckUnavailableHTTPError) + def get(self, connection_id: UUID): + identity = _identity() + with _errors(): + result = application_services().consumer.connection_revision(identity, str(connection_id)) + return dump_response(ConsumerConnectionRevisionResponse, result), 200, {"Cache-Control": "no-store"} + + +@console_ns.route("/me/connections//credential-schema") +class ConsumerCredentialSchemaApi(Resource): + @console_ns.doc(params=query_params_from_model(ConsumerConnectionRevisionQuery)) + @console_ns.response( + 200, "API-key replacement schema", console_ns.models[ConsumerCredentialSchemaResponse.__name__] + ) + @error_responses( + ConnectionNotFoundHTTPError, + ConnectionConfigurationHTTPError, + ConnectionRevisionChangedHTTPError, + ConnectionCheckUnavailableHTTPError, + ) + def get(self, connection_id: UUID): + identity = _identity() + with _errors(): + query = ConsumerConnectionRevisionQuery.model_validate(request.args.to_dict(flat=True)) + result = application_services().consumer.credential_schema( + identity, + str(connection_id), + connection_revision=query.connection_revision, + ) + return dump_response(ConsumerCredentialSchemaResponse, result), 200, {"Cache-Control": "no-store"} + + +@console_ns.route("/me/connections//reauthorize") +class ConsumerReauthorizeApi(Resource): + @console_ns.expect(console_ns.models[ConsumerReauthorizePayload.__name__]) + @console_ns.response(200, "OAuth reauthorization URL", console_ns.models[ConsumerOAuthResponse.__name__]) + @error_responses( + ConnectionNotFoundHTTPError, + ConnectionConfigurationHTTPError, + ConnectionRevisionChangedHTTPError, + ConnectionCheckUnavailableHTTPError, + ConnectionSecretUnavailableHTTPError, + ConnectionOAuthHTTPError, + ConnectionReauthorizationHTTPError, + ) + def post(self, connection_id: UUID): + identity = _identity() + with _errors(): + command = ConsumerReauthorizePayload.model_validate(console_ns.payload or {}) + result = application_services().consumer.reauthorize( + identity, + str(connection_id), + connection_revision=command.connection_revision, + callback_url=request.url_root.rstrip("/") + "/console/api/me/connections/oauth", + ) + response = make_response(dump_response(ConsumerOAuthResponse, result)) + response.set_cookie( + "context_id", + result.context_id, + httponly=True, + samesite="Lax", + secure=request.is_secure, + max_age=OAuthProxyService.__MAX_AGE__, + ) + response.headers["Cache-Control"] = "no-store" + # response-contract:ignore cookie-bearing OAuth response + return response + + +@console_ns.route("/me/connections/oauth//callback") +class ConsumerOAuthCallbackApi(Resource): + @console_ns.response(302, "Redirect to OAuth completion page") + @error_responses( + ConnectionConfigurationHTTPError, + ConnectionConflictHTTPError, + ConnectionNotFoundHTTPError, + ConnectionOAuthHTTPError, + ConnectionReauthorizationHTTPError, + ConnectionSecretUnavailableHTTPError, + ConnectionRevisionChangedHTTPError, + ConnectionCheckUnavailableHTTPError, + ) + def get(self, provider: str): + # The existing one-use context cookie and Provider state admit this continuation. + # response-contract:ignore cookie-bearing OAuth redirect + return complete_end_user_connection_oauth(provider, request.cookies.get("context_id", "")) diff --git a/api/controllers/web/connections.py b/api/controllers/web/connections.py index 8a04891851847a..bce08b1e3b8506 100644 --- a/api/controllers/web/connections.py +++ b/api/controllers/web/connections.py @@ -1,12 +1,10 @@ """Web management of personal API-key and OAuth Connections.""" from datetime import datetime -from typing import Any from uuid import UUID from flask import request -from pydantic import ConfigDict, Field, JsonValue, RootModel, ValidationError, field_serializer -from pydantic.json_schema import SkipJsonSchema +from pydantic import Field, RootModel, ValidationError, field_serializer from werkzeug.exceptions import NotFound from controllers.common.end_user_connections import ( @@ -19,6 +17,7 @@ ConnectionReauthorizationHTTPError, ConnectionRevisionChangedHTTPError, ConnectionSecretUnavailableHTTPError, + ConnectionUpdatePayload, ) from controllers.common.end_user_connections import ( raise_connection_error as _raise_error, @@ -41,10 +40,8 @@ ConnectionTargetInput, CreateApiKeyConnectionInput, ListConnectionsInput, - UpdateConnectionInput, ) from services.end_user_connections.errors import ConnectionError -from services.end_user_connections.revision import ConnectionRevision from services.end_user_connections.selections import SaveConnectionConfigurationInput from services.errors.conversation import ConversationNotExistsError @@ -145,27 +142,8 @@ class EndUserConnectionCreatePayload(CreateApiKeyConnectionInput): pass -def _omit_schema_default(schema: dict[str, Any]) -> None: - schema.pop("default", None) - - -class EndUserConnectionUpdatePayload(UpdateConnectionInput): - model_config = ConfigDict( - json_schema_extra={"minProperties": 1, "dependentRequired": {"credentials": ["connection_revision"]}} - ) - - connection_revision: ConnectionRevision | SkipJsonSchema[None] = Field( - default=None, - json_schema_extra=_omit_schema_default, - description="Provider revision required when replacing credentials.", - ) - - name: str | SkipJsonSchema[None] = Field( - default=None, min_length=1, max_length=100, json_schema_extra=_omit_schema_default - ) - credentials: dict[str, JsonValue] | SkipJsonSchema[None] = Field( - default=None, min_length=1, repr=False, json_schema_extra=_omit_schema_default - ) +class EndUserConnectionUpdatePayload(ConnectionUpdatePayload): + pass class EndUserConnectionRevisionResponse(ConnectionRevisionView, ResponseModel): diff --git a/api/extensions/application_services/consumer.py b/api/extensions/application_services/consumer.py new file mode 100644 index 00000000000000..5f2d6fcbccb069 --- /dev/null +++ b/api/extensions/application_services/consumer.py @@ -0,0 +1,34 @@ +"""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.end_user_connections.oauth import OAuthConnectionService +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, + oauth: OAuthConnectionService, + 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, + oauth=oauth, + identities=identities, + tokens=tokens, + ) diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index ed90b02115b6ca..e5978df1e1e7ba 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -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, @@ -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 @@ -292,6 +294,7 @@ class AppScopedEndUserServices: @dataclass(frozen=True, slots=True) class ApplicationServices: + consumer: ConsumerService rbac: RBACServices agent_apps: AgentAppServices advanced_prompt_templates: AdvancedPromptTemplateService @@ -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 ) @@ -612,7 +616,22 @@ 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, + oauth=end_user_connection_oauth, + identities=web_identity, + tokens=web_passport_tokens, + ), rbac=rbac, accounts=account_services, apps=apps, @@ -702,12 +721,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, @@ -844,7 +858,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), diff --git a/api/migrations/versions/2026_10_30_1600-b73e9c40a215_add_account_app_usage.py b/api/migrations/versions/2026_10_30_1600-b73e9c40a215_add_account_app_usage.py new file mode 100644 index 00000000000000..f7afd22639f4f2 --- /dev/null +++ b/api/migrations/versions/2026_10_30_1600-b73e9c40a215_add_account_app_usage.py @@ -0,0 +1,36 @@ +"""Record personal App usage independently of workspace installations. + +Revision ID: b73e9c40a215 +Revises: a91d5e7c3b20 +Create Date: 2026-10-30 16:00:00.000000 +""" + +import sqlalchemy as sa +from alembic import op + +from models.types import StringUUID + +revision = "b73e9c40a215" +down_revision = "a91d5e7c3b20" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "account_app_usages", + sa.Column("id", StringUUID(), nullable=False), + sa.Column("account_id", StringUUID(), nullable=False), + sa.Column("tenant_id", StringUUID(), nullable=False), + sa.Column("app_id", StringUUID(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), 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="account_app_usages_pkey"), + sa.UniqueConstraint("account_id", "tenant_id", "app_id", name="account_app_usage_owner_key"), + ) + op.create_index("account_app_usage_recent_idx", "account_app_usages", ["account_id", "last_used_at", "app_id"]) + + +def downgrade() -> None: + op.drop_table("account_app_usages") diff --git a/api/models/__init__.py b/api/models/__init__.py index 17d0eb5a21baf7..b8b4a4ff10aaf5 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -38,6 +38,7 @@ WorkflowCommentMention, WorkflowCommentReply, ) +from .consumer import AccountAppUsage from .credential_permission import CredentialPermission from .credential_permission import CredentialType as CredentialPermissionType from .dataset import ( @@ -214,6 +215,7 @@ "APIBasedExtension", "APIBasedExtensionPoint", "Account", + "AccountAppUsage", "AccountIntegrate", "AccountStatus", "AccountStepByStepTourState", diff --git a/api/models/consumer.py b/api/models/consumer.py new file mode 100644 index 00000000000000..0b0f294c2f40ff --- /dev/null +++ b/api/models/consumer.py @@ -0,0 +1,22 @@ +"""Account-owned usage of published Apps, independent of workspace installations.""" + +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import DefaultFieldsDCMixin, TypeBase +from models.types import StringUUID + + +class AccountAppUsage(DefaultFieldsDCMixin, TypeBase): + __tablename__ = "account_app_usages" + __table_args__ = ( + sa.UniqueConstraint("account_id", "tenant_id", "app_id", name="account_app_usage_owner_key"), + sa.Index("account_app_usage_recent_idx", "account_id", "last_used_at", "app_id"), + ) + + account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + last_used_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index f930ba2f0e3062..152dfc77d67d88 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -11099,6 +11099,200 @@ Called by /signin in the Account origin before showing a login form and after su | 302 | Redirect to OAuth callback page | | | default | Error | **application/json**: [ErrorResponse](#errorresponse)
| +### [GET] /me/apps +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| cursor | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| name | query | | No | string | +| view | query | | No | string,
**Available values:** "favorites", "recent", "shared",
**Default:** recent | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Accessible personal Apps | **application/json**: [ConsumerAppsResponse](#consumerappsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /me/apps/{app_id}/favorite +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 204 | App unfavorited | | +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PUT] /me/apps/{app_id}/favorite +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| app_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 204 | App favorited | | +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /me/connections +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| cursor | query | | No | string | +| limit | query | | No | integer,
**Default:** 20 | +| provider | query | | No | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Account Connections | **application/json**: [ConsumerConnectionsResponse](#consumerconnectionsresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /me/connections/oauth/{provider}/callback +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| provider | path | | Yes | string | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 302 | Redirect to OAuth completion page | | +| 400 | Bad Request | **application/json**: [ConnectionConfigurationInvalidError](#connectionconfigurationinvaliderror)
[ConnectionOauthFailedError](#connectionoauthfailederror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [ConnectionConflictError](#connectionconflicterror)
[ConnectionReauthorizationRequiredError](#connectionreauthorizationrequirederror)
[ConnectionRevisionChangedError](#connectionrevisionchangederror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [ConnectionCredentialsUnavailableError](#connectioncredentialsunavailableerror)
[CredentialCheckUnavailableError](#credentialcheckunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [DELETE] /me/connections/{connection_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 204 | Connection disconnected | | +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [ConnectionConflictError](#connectionconflicterror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /me/connections/{connection_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Owned Connection | **application/json**: [ConsumerConnectionDetailResponse](#consumerconnectiondetailresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [PATCH] /me/connections/{connection_id} +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConsumerConnectionUpdatePayload](#consumerconnectionupdatepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Connection updated | **application/json**: [ConsumerConnectionResponse](#consumerconnectionresponse)
| +| 400 | Bad Request | **application/json**: [ConnectionConfigurationInvalidError](#connectionconfigurationinvaliderror)
[ConnectionCredentialsInvalidError](#connectioncredentialsinvaliderror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [ConnectionConflictError](#connectionconflicterror)
[ConnectionRevisionChangedError](#connectionrevisionchangederror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [ConnectionCredentialsUnavailableError](#connectioncredentialsunavailableerror)
[CredentialCheckUnavailableError](#credentialcheckunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /me/connections/{connection_id}/credential-schema +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_revision | query | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | string (uuid) | +| connection_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | API-key replacement schema | **application/json**: [ConsumerCredentialSchemaResponse](#consumercredentialschemaresponse)
| +| 400 | Bad Request | **application/json**: [ConnectionConfigurationInvalidError](#connectionconfigurationinvaliderror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [ConnectionRevisionChangedError](#connectionrevisionchangederror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [CredentialCheckUnavailableError](#credentialcheckunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [POST] /me/connections/{connection_id}/reauthorize +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_id | path | | Yes | string (uuid) | + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [ConsumerReauthorizePayload](#consumerreauthorizepayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | OAuth reauthorization URL | **application/json**: [ConsumerOAuthResponse](#consumeroauthresponse)
| +| 400 | Bad Request | **application/json**: [ConnectionConfigurationInvalidError](#connectionconfigurationinvaliderror)
[ConnectionOauthFailedError](#connectionoauthfailederror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 409 | Conflict | **application/json**: [ConnectionReauthorizationRequiredError](#connectionreauthorizationrequirederror)
[ConnectionRevisionChangedError](#connectionrevisionchangederror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [ConnectionCredentialsUnavailableError](#connectioncredentialsunavailableerror)
[CredentialCheckUnavailableError](#credentialcheckunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + +### [GET] /me/connections/{connection_id}/revision +#### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| connection_id | path | | Yes | string (uuid) | + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Current Provider revision | **application/json**: [ConsumerConnectionRevisionResponse](#consumerconnectionrevisionresponse)
| +| 400 | Bad Request | **application/json**: [ConnectionConfigurationInvalidError](#connectionconfigurationinvaliderror)
[ErrorResponse](#errorresponse)
| +| 404 | Not Found | **application/json**: [ConnectionNotFoundError](#connectionnotfounderror)
[ErrorResponse](#errorresponse)
| +| 503 | Service Unavailable | **application/json**: [CredentialCheckUnavailableError](#credentialcheckunavailableerror)
[ErrorResponse](#errorresponse)
| +| default | Error | **application/json**: [ErrorResponse](#errorresponse)
| + ### [GET] /notification Return the active in-product notification for the current user in the requested language (defaults to English when omitted). Unavailable translations fall back to English, then the first available content. The notification is NOT marked as seen here; call POST /notification/dismiss when the user explicitly closes the modal. @@ -22843,6 +23037,63 @@ Enum class for configurate method of provider model. | ---- | ---- | ----------- | -------- | | ConfigurateMethod | string | Enum class for configurate method of provider model. | | +#### ConnectionConfigurationInvalidError + +The connection is not compatible with this tool configuration. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionConfigurationInvalidError | | The connection is not compatible with this tool configuration. | | + +#### ConnectionConflictError + +Connection changed during the operation. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionConflictError | | Connection changed during the operation. | | + +#### ConnectionCredentialStatus + +Last known credential lifecycle state, not readiness for a particular node. + +Keep the existing storage values. App-specific Client mismatches and unbinding +must never be written back as a global credential state change. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionCredentialStatus | string | Last known credential lifecycle state, not readiness for a particular node. Keep the existing storage values. App-specific Client mismatches and unbinding must never be written back as a global credential state change. | | + +#### ConnectionCredentialType + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionCredentialType | string | | | + +#### ConnectionCredentialsInvalidError + +Invalid credentials for this provider. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionCredentialsInvalidError | | Invalid credentials for this provider. | | + +#### ConnectionCredentialsUnavailableError + +Connection credentials are temporarily unavailable. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionCredentialsUnavailableError | | Connection credentials are temporarily unavailable. | | + +#### ConnectionNotFoundError + +Connection not found. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionNotFoundError | | Connection not found. | | + #### ConnectionOauthFailedError OAuth authorization could not be completed. Start authorization again. @@ -22851,6 +23102,28 @@ OAuth authorization could not be completed. Start authorization again. | ---- | ---- | ----------- | -------- | | ConnectionOauthFailedError | | OAuth authorization could not be completed. Start authorization again. | | +#### ConnectionReauthorizationRequiredError + +The OAuth client changed or is unavailable. Start authorization again. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionReauthorizationRequiredError | | The OAuth client changed or is unavailable. Start authorization again. | | + +#### ConnectionRevisionChangeDetails + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| connection_revision | string | | Yes | + +#### ConnectionRevisionChangedError + +The connection requirements changed. Refresh and confirm again. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConnectionRevisionChangedError | | The connection requirements changed. Refresh and confirm again. | | + #### ConsoleDatasetListQuery | Name | Type | Description | Required | @@ -22885,6 +23158,91 @@ OAuth authorization could not be completed. Start authorization again. | total | integer | | Yes | | total_pages | integer | | Yes | +#### ConsumerAppResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| app_code | string | | Yes | +| description | string | | Yes | +| icon | string | | Yes | +| icon_background | string | | Yes | +| icon_type | string | | Yes | +| id | string | | Yes | +| is_favorite | boolean | | Yes | +| last_used_at | integer | | Yes | +| mode | string | | Yes | +| name | string | | Yes | + +#### ConsumerAppsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ConsumerAppResponse](#consumerappresponse) ] | | Yes | +| has_more | boolean | | Yes | +| next_cursor | string | | Yes | + +#### ConsumerConnectionDetailResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| apps | [ [ConsumerAppResponse](#consumerappresponse) ] | | Yes | +| connection | [ConsumerConnectionResponse](#consumerconnectionresponse) | | Yes | + +#### ConsumerConnectionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| account_label | string | | Yes | +| created_at | integer | | Yes | +| credential_status | [ConnectionCredentialStatus](#connectioncredentialstatus) | | Yes | +| credential_type | [ConnectionCredentialType](#connectioncredentialtype) | | Yes | +| expires_at | integer | | Yes | +| id | string | | Yes | +| last_active_at | integer | | Yes | +| name | string | | Yes | +| provider | string | | Yes | +| updated_at | integer | | Yes | + +#### ConsumerConnectionRevisionResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| connection_revision | string (uuid) | Provider revision scoped to this Web App, for operations on the owned Connection. | Yes | + +#### ConsumerConnectionUpdatePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| connection_revision | string (uuid) | Provider revision required when replacing credentials. | No | +| credentials | object | | No | +| name | string | | No | + +#### ConsumerConnectionsResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [ConsumerConnectionDetailResponse](#consumerconnectiondetailresponse) ] | | Yes | +| has_more | boolean | | Yes | +| next_cursor | string | | Yes | + +#### ConsumerCredentialSchemaResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| ConsumerCredentialSchemaResponse | array | | | + +#### ConsumerOAuthResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| authorization_url | string | | Yes | + +#### ConsumerReauthorizePayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | + #### Conversation | Name | Type | Description | Required | @@ -23177,6 +23535,14 @@ Payload for creating a new snippet. | ---- | ---- | ----------- | -------- | | CreatorUserRole | string | | | +#### CredentialCheckUnavailableError + +Credential validation is temporarily unavailable. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| CredentialCheckUnavailableError | | Credential validation is temporarily unavailable. | | + #### CredentialConfiguration Model class for credential configuration. diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index c389c111dc49bb..1a026d5dc0bc8a 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -222,7 +222,7 @@ Read API-key form fields for an authorized published tool node; no credential va | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | -| connection_revision | query | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | string (uuid) | +| connection_revision | query | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | string (uuid) | | node_id | query | | Yes | string | #### Responses @@ -1627,7 +1627,7 @@ The connection is not compatible with this tool configuration. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| connection_revision | string (uuid) | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | | connections | object | Complete choices keyed by independent authorization node ID. Missing/null nodes are unselected; an empty map clears the configuration. Saving explicitly approves the submitted authorization contract. | Yes | | conversation_id | string | Existing Chatflow conversation. Omit for a new conversation or plain Workflow. | No | @@ -1838,7 +1838,7 @@ Too many emails have been sent from this IP address recently. Please try again l | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| connection_revision | string (uuid) | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | | credentials | object | | Yes | | name | string | Omit or leave empty to generate an incremental API KEY name. | No | | node_id | string | | Yes | @@ -1863,7 +1863,7 @@ Too many emails have been sent from this IP address recently. Please try again l | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| connection_revision | string (uuid) | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | | name | string | Omit or leave empty to generate an incremental AUTH name. | No | | node_id | string | | Yes | @@ -1877,7 +1877,7 @@ Too many emails have been sent from this IP address recently. Please try again l | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| connection_revision | string (uuid) | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | | connections | object | Explicit choices keyed by independent authorization node ID. Missing/null choices remain disconnected; saved choices are never inferred. | Yes | | conversation_id | string | Existing Chatflow conversation. Omit for a new conversation or plain Workflow. | No | @@ -1893,7 +1893,7 @@ Too many emails have been sent from this IP address recently. Please try again l | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| connection_revision | string (uuid) | Opaque revision issued for the current Web App. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | +| connection_revision | string (uuid) | Opaque revision issued for the current Web App or owned-Connection management context. Use App parameters for tool-node operations or the owned Connection revision endpoint for Provider management. | Yes | #### EndUserConnectionResponse diff --git a/api/repositories/app/console_repository.py b/api/repositories/app/console_repository.py index 08787fde1393b1..7094d52aada8e1 100644 --- a/api/repositories/app/console_repository.py +++ b/api/repositories/app/console_repository.py @@ -7,24 +7,29 @@ from __future__ import annotations +import base64 +import json import logging from collections.abc import Sequence from dataclasses import replace from datetime import datetime from typing import Any, cast, override +from uuid import UUID import sqlalchemy as sa from pydantic import TypeAdapter from sqlalchemy import ColumnElement, delete, select +from sqlalchemy.engine import Row from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot +from core.app.public_runtime import published_app_filter from core.trigger.constants import TRIGGER_NODE_TYPES from libs.datetime_utils import naive_utc_now from libs.pagination import PaginatedResult, paginate_query from machinery.context import AppRequestContext, RequestContext -from models.account import Account, Tenant +from models.account import Account, AccountStatus, Tenant from models.agent import ( APP_BACKED_AGENT_SOURCES, Agent, @@ -35,8 +40,9 @@ WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) +from models.consumer import AccountAppUsage from models.enums import AppStatus -from models.model import App, AppMode, AppModelConfig, AppStar, IconType +from models.model import App, AppMode, AppModelConfig, AppStar, IconType, Site from models.skill import AgentSkillBinding from models.workflow import Workflow from repositories.app.response import app_record, app_summary @@ -50,6 +56,7 @@ from services.app.query_service import AppDescription, DiscoveryApps, RelatedApps from services.app_creation_records import create_installed_app_record, create_site_record from services.app_tracing_config_service import AppTraceSettings +from services.consumer_service import ConsumerApp, ConsumerAppNotFoundError, ConsumerAppsQuery, ConsumerIdentity from services.entities.app_entities import ( RECENT_APP_MODES, AppAgentBinding, @@ -78,6 +85,8 @@ logger = logging.getLogger(__name__) +type ConsumerAppRow = tuple[App, str, str | None, datetime | None, datetime | None] + _app_trace_settings_adapter = TypeAdapter(AppTraceSettings) @@ -121,6 +130,199 @@ class ConsoleAppRepository(ConsoleApps, DiscoveryApps, RelatedApps): def __init__(self, *, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory + @staticmethod + def _consumer_query(identity: ConsumerIdentity) -> sa.Select[ConsumerAppRow]: + stmt = ( + select(App, Site.code, AppStar.id, AccountAppUsage.last_used_at, AppStar.created_at) + .join(Site, Site.app_id == App.id) + .outerjoin( + AppStar, + sa.and_( + AppStar.tenant_id == App.tenant_id, + AppStar.app_id == App.id, + AppStar.account_id == identity.account_id, + ), + ) + .outerjoin( + AccountAppUsage, + sa.and_( + AccountAppUsage.tenant_id == App.tenant_id, + AccountAppUsage.app_id == App.id, + AccountAppUsage.account_id == identity.account_id, + ), + ) + .where( + App.status == AppStatus.NORMAL, + App.enable_site.is_(True), + Site.status == AppStatus.NORMAL, + Site.code.is_not(None), + Site.code != "", + published_app_filter(), + ) + ) + # SQLAlchemy does not infer outer-join nullability or the Site code predicate. + return cast(sa.Select[ConsumerAppRow], stmt) + + @staticmethod + def _consumer_app(identity: ConsumerIdentity, row: Row[ConsumerAppRow], view: str) -> ConsumerApp: + app, code, star_id, last_used, starred_at = row + timestamp = last_used if view == "recent" else starred_at if view == "favorites" else None + continuation = base64.urlsafe_b64encode( + json.dumps( + [ + identity.account_id, + view, + timestamp.isoformat() if timestamp is not None else None, + app.id, + ] + ).encode() + ).decode() + return ConsumerApp( + id=app.id, + name=app.name, + description=app.description, + mode=app.mode, + icon_type=app.icon_type, + icon=app.icon, + icon_background=app.icon_background, + app_code=code, + is_favorite=star_id is not None, + last_used_at=last_used, + continuation=continuation, + ) + + def candidates( + self, + identity: ConsumerIdentity, + query: ConsumerAppsQuery, + *, + after: str | None, + limit: int, + shared_ids: Sequence[str] | None, + ) -> tuple[ConsumerApp, ...]: + stmt = self._consumer_query(identity) + timestamp = None + if query.view == "recent": + stmt = stmt.where(AccountAppUsage.id.is_not(None)) + timestamp = AccountAppUsage.last_used_at + elif query.view == "favorites": + stmt = stmt.where(AppStar.id.is_not(None)) + timestamp = AppStar.created_at + else: + stmt = stmt.where(App.id.in_(shared_ids or ())) + if query.name: + stmt = stmt.where(App.name.icontains(query.name, autoescape=True)) + if after is not None: + try: + owner, view, time_value, app_id = json.loads(base64.b64decode(after, altchars=b"-_", validate=True)) + if owner != identity.account_id or view != query.view: + raise ValueError + app_id = str(UUID(app_id)) + if timestamp is None: + stmt = stmt.where(App.id > app_id) + else: + moment = datetime.fromisoformat(time_value) + stmt = stmt.where(sa.or_(timestamp < moment, sa.and_(timestamp == moment, App.id > app_id))) + except (ValueError, TypeError, UnicodeError): + raise ValueError("Invalid consumer App cursor.") from None + if timestamp is not None: + stmt = stmt.order_by(timestamp.desc()) + stmt = stmt.order_by(App.id).limit(limit) + with self._session_factory() as session: + return tuple(self._consumer_app(identity, row, query.view) for row in session.execute(stmt)) + + def by_ids(self, identity: ConsumerIdentity, app_ids: Sequence[str]) -> tuple[ConsumerApp, ...]: + if not app_ids: + return () + with self._session_factory() as session: + return tuple( + self._consumer_app(identity, row, "shared") + for row in session.execute(self._consumer_query(identity).where(App.id.in_(app_ids)).order_by(App.id)) + ) + + def favorite(self, identity: ConsumerIdentity, app_id: str, *, enabled: bool) -> None: + with self._session_factory.begin() as session: + account = session.scalar( + select(Account.id) + .where( + Account.id == identity.account_id, + Account.status.in_((AccountStatus.ACTIVE, AccountStatus.UNINITIALIZED)), + ) + .with_for_update() + ) + if account is None: + raise ConsumerAppNotFoundError() + if not enabled: + star = session.scalar( + select(AppStar) + .where( + AppStar.account_id == identity.account_id, + AppStar.app_id == app_id, + ) + .limit(1) + ) + if star is not None: + session.execute( + delete(AppStar).where( + AppStar.id == star.id, + AppStar.account_id == identity.account_id, + AppStar.tenant_id == star.tenant_id, + AppStar.app_id == app_id, + ) + ) + return + row = session.execute(self._consumer_query(identity).where(App.id == app_id)).first() + if row is None: + raise ConsumerAppNotFoundError() + app, _, star_id, _, _ = row + if star_id is None: + try: + with session.begin_nested(): + session.add(AppStar(tenant_id=app.tenant_id, account_id=identity.account_id, app_id=app.id)) + session.flush() + except IntegrityError: + # Console writers share the unique key without taking the consumer lock. + if ( + session.scalar( + select(AppStar.id).where( + AppStar.tenant_id == app.tenant_id, + AppStar.account_id == identity.account_id, + AppStar.app_id == app_id, + ) + ) + is None + ): + raise + + def record_consumer_usage(self, *, account_id: str, tenant_id: str, app_id: str, used_at: datetime) -> None: + """Serialize first use on the Account and keep the timestamp monotonic.""" + with self._session_factory.begin() as session: + account = session.scalar( + select(Account.id) + .where( + Account.id == account_id, + Account.status.in_((AccountStatus.ACTIVE, AccountStatus.UNINITIALIZED)), + ) + .with_for_update() + ) + if account is None: + return + if session.scalar(select(App.id).where(App.id == app_id, App.tenant_id == tenant_id)) is None: + return + usage = session.scalar( + select(AccountAppUsage).where( + AccountAppUsage.account_id == account_id, + AccountAppUsage.tenant_id == tenant_id, + AccountAppUsage.app_id == app_id, + ) + ) + if usage is None: + session.add( + AccountAppUsage(account_id=account_id, tenant_id=tenant_id, app_id=app_id, last_used_at=used_at) + ) + elif usage.last_used_at < used_at: + usage.last_used_at = used_at + def get_agent_binding(self, workspace_id: str, app_id: str) -> AppAgentBinding | None: with self._session_factory() as session: app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == workspace_id)) diff --git a/api/repositories/end_user_connection_repository.py b/api/repositories/end_user_connection_repository.py index f8dc315e20a553..5a8b51ba678b3d 100644 --- a/api/repositories/end_user_connection_repository.py +++ b/api/repositories/end_user_connection_repository.py @@ -1,9 +1,10 @@ """Owner-scoped Connection storage; external I/O never runs in these transactions.""" -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime from hashlib import sha256 from typing import Any, cast +from uuid import UUID import sqlalchemy as sa from sqlalchemy.engine import CursorResult @@ -33,10 +34,13 @@ from models.provider_ids import ToolProviderID from models.tools import ToolOAuthSystemClient, ToolOAuthTenantClient from models.workflow import Workflow +from services.consumer_service import ConsumerIdentity from services.end_user_connections.claim import ConnectionClaimSource from services.end_user_connections.contracts import ConnectionPage, ConnectionSummary, ListConnectionsInput from services.end_user_connections.entities import ( + AccountConnectionContext, ConnectionAccessRecord, + ConnectionManagementContext, ConnectionMutationGuard, ConnectionOAuthClient, ConnectionOwner, @@ -46,6 +50,7 @@ ConnectionConfigurationError, ConnectionConflictError, ConnectionIdentityError, + ConnectionNotFoundError, ConnectionReauthorizationRequiredError, ConnectionRevisionChangedError, ) @@ -69,6 +74,116 @@ class EndUserConnectionRepository: def __init__(self, *, session_factory: sessionmaker[Session]) -> None: self._sessions = session_factory + def account_connection_context(self, identity: ConsumerIdentity, connection_id: str) -> AccountConnectionContext: + with self._sessions() as session: + tenant_id = session.scalar( + sa.select(EndUserConnection.tenant_id).where( + EndUserConnection.id == connection_id, + EndUserConnection.account_id == identity.account_id, + EndUserConnection.end_user_id.is_(None), + ) + ) + if tenant_id is None: + raise ConnectionNotFoundError() + context = AccountConnectionContext(tenant_id, identity.account_id, connection_id) + self._require_management_context(session, context, lock=False) + return context + + def account_connections( + self, identity: ConsumerIdentity, *, provider: str | None, after: str | None, limit: int + ) -> tuple[ConnectionSummary, ...]: + """Discover an Account's own resources across tenant namespaces without secrets. + + This is an explicit Account-wide query, independent of memberships. Each + mutation subsequently resolves and revalidates the resource's tenant. + """ + with self._sessions() as session: + stmt = ( + sa.select(EndUserConnection) + .options( + load_only( + EndUserConnection.id, + EndUserConnection.name, + EndUserConnection.provider, + EndUserConnection.credential_type, + EndUserConnection.status, + EndUserConnection.oauth_client_fingerprint, + EndUserConnection.oauth_plugin_unique_identifier, + EndUserConnection.account_label, + EndUserConnection.expires_at, + EndUserConnection.created_at, + EndUserConnection.updated_at, + EndUserConnection.last_active_at, + raiseload=True, + ) + ) + .where( + EndUserConnection.account_id == identity.account_id, + EndUserConnection.end_user_id.is_(None), + ) + ) + if provider is not None: + stmt = stmt.where(EndUserConnection.provider == provider) + if after is not None: + stmt = stmt.where(EndUserConnection.id > str(UUID(after))) + return tuple( + self._summary(row) for row in session.scalars(stmt.order_by(EndUserConnection.id).limit(limit)) + ) + + def account_connection_apps( + self, identity: ConsumerIdentity, connection_ids: Sequence[str] + ) -> Mapping[str, tuple[str, ...]]: + if not connection_ids: + return {} + queries = [ + sa.select(reference.connection_id, reference.app_id) + .join( + EndUserConnection, + sa.and_( + EndUserConnection.id == reference.connection_id, + EndUserConnection.tenant_id == reference.tenant_id, + ), + ) + .join(App, sa.and_(App.id == reference.app_id, App.tenant_id == reference.tenant_id)) + .where( + EndUserConnection.account_id == identity.account_id, + EndUserConnection.end_user_id.is_(None), + EndUserConnection.id.in_(connection_ids), + ) + for reference in (AppConnection, SessionConnection) + ] + with self._sessions() as session: + rows = session.execute(sa.union(*queries).order_by("connection_id", "app_id")) + associations: dict[str, list[str]] = {} + for connection_id, app_id in rows: + associations.setdefault(connection_id, []).append(app_id) + return {key: tuple(ids) for key, ids in associations.items()} + + @classmethod + def _require_management_context( + cls, session: Session, context: ConnectionManagementContext, *, lock: bool + ) -> App | None: + if isinstance(context, WebConnectionContext): + return cls._require_context(session, context, lock=lock) + account = sa.select(Account.id).where( + Account.id == context.account_id, + Account.status.in_((AccountStatus.ACTIVE, AccountStatus.UNINITIALIZED)), + ) + if lock: + account = account.with_for_update() + if session.scalar(account) is None: + raise ConnectionIdentityError() + connection = sa.select(EndUserConnection.id).where( + EndUserConnection.id == context.connection_id, + EndUserConnection.tenant_id == context.tenant_id, + *cls._owner(context.owner), + ) + if lock: + connection = connection.with_for_update() + if session.scalar(connection) is None: + raise ConnectionNotFoundError() + return None + def save_revision(self, snapshot: ConnectionContractSnapshot) -> str: canonical = snapshot.canonical_json() fingerprint = sha256(canonical.encode()).hexdigest() @@ -698,7 +813,7 @@ def _insert_conversation_selections( ) @classmethod - def _summary_query(cls, context: WebConnectionContext) -> sa.Select[tuple[EndUserConnection]]: + def _summary_query(cls, context: ConnectionManagementContext) -> sa.Select[tuple[EndUserConnection]]: return ( sa.select(EndUserConnection) .options( @@ -743,9 +858,11 @@ def list(self, context: WebConnectionContext, query: ListConnectionsInput, after data = tuple(self._summary(row) for row in rows[: query.limit]) return ConnectionPage(data=data, next_cursor=data[-1].id if len(rows) > query.limit else None) - def find(self, context: WebConnectionContext, connection_id: str) -> StoredConnection | None: + def find(self, context: ConnectionManagementContext, connection_id: str) -> StoredConnection | None: + if isinstance(context, AccountConnectionContext) and connection_id != context.connection_id: + return None with self._sessions() as session: - self._require_context(session, context, lock=False) + self._require_management_context(session, context, lock=False) row = session.scalar( sa.select(EndUserConnection).where( EndUserConnection.id == connection_id, @@ -791,10 +908,10 @@ def create_api_key( return self._summary(row) def update( - self, context: WebConnectionContext, guard: ConnectionMutationGuard, changes: ConnectionChanges + self, context: ConnectionManagementContext, guard: ConnectionMutationGuard, changes: ConnectionChanges ) -> StoredConnection: with self._sessions.begin() as session: - self._require_context(session, context, lock=True) + self._require_management_context(session, context, lock=True) predicates = self._guard(context, guard) values: dict[str, Any] = {} if changes.name is not None: @@ -823,13 +940,13 @@ def update( row = session.scalar(sa.select(EndUserConnection).where(*predicates).with_for_update()) if row is None: raise ConnectionConflictError() - if changes.revision_acceptance is not None: + if changes.revision_acceptance is not None and isinstance(context, WebConnectionContext): self._record_revision_acceptance(session, context, changes.revision_acceptance) return self._stored(row) - def delete(self, context: WebConnectionContext, guard: ConnectionMutationGuard) -> None: + def delete(self, context: ConnectionManagementContext, guard: ConnectionMutationGuard) -> None: with self._sessions.begin() as session: - self._require_context(session, context, lock=True) + self._require_management_context(session, context, lock=True) result = cast( CursorResult, session.execute(sa.delete(EndUserConnection).where(*self._guard(context, guard))) ) @@ -941,7 +1058,7 @@ def update_oauth_check( def validate_oauth_context(self, attempt: ConnectionOAuthAttempt) -> None: with self._sessions() as session: - self._require_context(session, attempt.context, lock=False) + self._require_management_context(session, attempt.context, lock=False) @staticmethod def _oauth_client_configuration( @@ -982,7 +1099,7 @@ def save_oauth( ) -> ConnectionSummary: context = attempt.context with self._sessions.begin() as session: - app = self._require_context(session, context, lock=True) + app = self._require_management_context(session, context, lock=True) client = attempt.client current = self._oauth_client_configuration( session, context.tenant_id, attempt.provider, client.source, lock=True @@ -994,7 +1111,7 @@ def save_oauth( ): raise ConnectionReauthorizationRequiredError() if attempt.guard is None: - if attempt.target is None: + if attempt.target is None or not isinstance(context, WebConnectionContext) or app is None: raise ConnectionConfigurationError() if attempt.target_workflow_id is None: raise ConnectionConfigurationError() @@ -1060,13 +1177,19 @@ def save_oauth( ) if row is None: raise ConnectionConflictError() - self._record_revision_acceptance(session, context, revision_acceptance) + if isinstance(context, WebConnectionContext): + self._record_revision_acceptance(session, context, revision_acceptance) return self._summary(row) def _guard( - self, context: WebConnectionContext, guard: ConnectionMutationGuard + self, context: ConnectionManagementContext, guard: ConnectionMutationGuard ) -> tuple[sa.ColumnElement[bool], ...]: - if guard.tenant_id != context.tenant_id or guard.owner != context.owner: + if ( + guard.tenant_id != context.tenant_id + or guard.owner != context.owner + or isinstance(context, AccountConnectionContext) + and guard.connection_id != context.connection_id + ): raise ConnectionConflictError() return ( EndUserConnection.id == guard.connection_id, diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 3a4cf408f5a5d5..6da950fd802d97 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -120,7 +120,7 @@ def generate( :param streaming: streaming :return: """ - return cls._run_with_guardrails( + result = cls._run_with_guardrails( app_model=app_model, streaming=streaming, action=lambda rate_limit, request_id: cls._dispatch_generate( @@ -135,6 +135,22 @@ def generate( request_id=request_id, ), ) + if invoke_from == InvokeFrom.WEB_APP and isinstance(user, EndUser) and user.account_id is not None: + from extensions.ext_application_services import application_services + from libs.datetime_utils import naive_utc_now + from services.consumer_service import ConsumerIdentity + + try: + application_services().consumer.record_usage( + ConsumerIdentity(user.account_id), + tenant_id=app_model.tenant_id, + app_id=app_model.id, + used_at=naive_utc_now(), + ) + except Exception: + # A preference write must not turn an accepted execution into a failed request. + logger.warning("Failed to record personal App usage for App %s", app_model.id) + return result @classmethod def _run_with_guardrails( diff --git a/api/services/consumer_adapters.py b/api/services/consumer_adapters.py new file mode 100644 index 00000000000000..c9a7c3a85a5910 --- /dev/null +++ b/api/services/consumer_adapters.py @@ -0,0 +1,36 @@ +"""Deployment discovery adapters for Account consumer queries.""" + +import httpx + +from services.enterprise.enterprise_service import EnterpriseService +from services.errors.enterprise import EnterpriseServiceError +from services.webapp_access_query_service import WebAppAccessUnavailableError + + +class SharedConsumerAppDiscovery: + def __init__(self, *, enabled: bool) -> None: + self._enabled = enabled + + def __call__(self) -> tuple[str, ...]: + if not self._enabled: + return () + app_ids: list[str] = [] + seen: set[str] = set() + page = 1 + while True: + try: + result = EnterpriseService.WebAppAuth.list_externally_accessible_apps(page=page, limit=100) + rows = result["data"] + ids = [row["appId"] for row in rows] + has_more = result["hasMore"] + if not isinstance(has_more, bool) or any(not isinstance(id_, str) for id_ in ids): + raise ValueError("Malformed App discovery response") + if has_more and not set(ids).difference(seen): + raise ValueError("App discovery did not advance") + except (EnterpriseServiceError, httpx.RequestError, KeyError, TypeError, ValueError) as error: + raise WebAppAccessUnavailableError from error + app_ids.extend(ids) + seen.update(ids) + if not has_more: + return tuple(dict.fromkeys(app_ids)) + page += 1 diff --git a/api/services/consumer_service.py b/api/services/consumer_service.py new file mode 100644 index 00000000000000..7b64d3992e7976 --- /dev/null +++ b/api/services/consumer_service.py @@ -0,0 +1,271 @@ +"""Account consumer queries and personal App preferences without Studio admission. + +Candidate discovery is not permission. Every App projection is filtered through +the existing deployment access policy, including Apps referenced by Connections. +Ports return detached, non-secret values and own short transactions. +""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Protocol + +from pydantic import BaseModel, ConfigDict, Field + +from core.entities.provider_entities import ProviderConfig +from services.end_user_connections.contracts import ( + ConnectionOAuthStart, + ConnectionRevisionView, + ConnectionSummary, + UpdateConnectionInput, +) +from services.end_user_connections.entities import AccountConnectionContext +from services.end_user_connections.management import ConnectionService +from services.end_user_connections.oauth import OAuthConnectionService +from services.web_identity_service import WebIdentityError, WebIdentityService +from services.web_passport_service import WebPassportTokenGateway + + +@dataclass(frozen=True, slots=True) +class ConsumerIdentity: + account_id: str + + +class ConsumerApp(BaseModel): + model_config = ConfigDict(frozen=True) + + id: str + name: str + description: str + mode: str + icon_type: str | None + icon: str | None + icon_background: str | None + app_code: str + is_favorite: bool + last_used_at: datetime | None + continuation: str = Field(exclude=True) + + +class ConsumerAppsQuery(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True) + + view: Literal["recent", "favorites", "shared"] = "recent" + name: str | None = Field(default=None, max_length=255) + cursor: str | None = Field(default=None, max_length=2048) + limit: int = Field(default=20, ge=1, le=100) + + +class ConsumerAppsPage(BaseModel): + data: tuple[ConsumerApp, ...] + has_more: bool + next_cursor: str | None + + +class ConsumerConnection(BaseModel): + connection: ConnectionSummary + apps: tuple[ConsumerApp, ...] + + +class ConsumerConnectionsPage(BaseModel): + data: tuple[ConsumerConnection, ...] + has_more: bool + next_cursor: str | None + + +class ConsumerAppNotFoundError(LookupError): + """An unavailable and a foreign App have the same public result.""" + + +class ConsumerAppsStore(Protocol): + def candidates( + self, + identity: ConsumerIdentity, + query: ConsumerAppsQuery, + *, + after: str | None, + limit: int, + shared_ids: Sequence[str] | None, + ) -> tuple[ConsumerApp, ...]: ... + + def by_ids(self, identity: ConsumerIdentity, app_ids: Sequence[str]) -> tuple[ConsumerApp, ...]: ... + + def favorite(self, identity: ConsumerIdentity, app_id: str, *, enabled: bool) -> None: + """Serialize on the Account; revalidate App availability in the write transaction. + + The existing tenant/Account/App unique key also prevents duplicate stars. + No workspace membership is required or created. + """ + ... + + def record_consumer_usage(self, *, account_id: str, tenant_id: str, app_id: str, used_at: datetime) -> None: + """Serialize first insertion on the Account and atomically preserve the latest time.""" + ... + + +class ConsumerConnectionsStore(Protocol): + def account_connections( + self, identity: ConsumerIdentity, *, provider: str | None, after: str | None, limit: int + ) -> tuple[ConnectionSummary, ...]: ... + + def account_connection_context( + self, identity: ConsumerIdentity, connection_id: str + ) -> AccountConnectionContext: ... + + def account_connection_apps( + self, identity: ConsumerIdentity, connection_ids: Sequence[str] + ) -> Mapping[str, tuple[str, ...]]: + """Current App defaults and Session references, deduplicated by App. + + References do not grant ownership. Filter Connections by Account and join + each reference on tenant as well as ID. Never read historical Run snapshots. + """ + ... + + +class ConsumerAppAccess(Protocol): + def get_visible_app_ids(self, *, user_id: str, app_ids: Sequence[str]) -> frozenset[str]: ... + + +class SharedConsumerApps(Protocol): + def __call__(self) -> tuple[str, ...]: ... + + +class ConsumerService: + def __init__( + self, + *, + apps: ConsumerAppsStore, + connections: ConsumerConnectionsStore, + access: ConsumerAppAccess, + shared_apps: SharedConsumerApps, + management: ConnectionService, + oauth: OAuthConnectionService, + identities: WebIdentityService, + tokens: WebPassportTokenGateway, + ) -> None: + self._apps = apps + self._connections = connections + self._access = access + self._shared_apps = shared_apps + self._management = management + self._oauth = oauth + self._identities = identities + self._tokens = tokens + + def authenticate(self, access_token: str | None) -> ConsumerIdentity: + if not access_token: + raise WebIdentityError("Account login required.") + claims = self._identities.resolve_login(self._tokens.verify(access_token)) + if claims.token_source not in {None, "webapp_account_login"} or claims.account_id is None: + raise WebIdentityError("Account login required.") + return ConsumerIdentity(claims.account_id) + + def _visible(self, identity: ConsumerIdentity, apps: Sequence[ConsumerApp]) -> tuple[ConsumerApp, ...]: + visible = self._access.get_visible_app_ids(user_id=identity.account_id, app_ids=tuple(app.id for app in apps)) + return tuple(app for app in apps if app.id in visible) + + def list_apps(self, identity: ConsumerIdentity, query: ConsumerAppsQuery) -> ConsumerAppsPage: + # The continuation is the last consumed candidate, including denied Apps. + # Fill pages after permission filtering rather than leaking candidate counts. + data: list[ConsumerApp] = [] + after = query.cursor + last = after + shared_ids = self._shared_apps() if query.view == "shared" else None + while True: + candidates = self._apps.candidates( + identity, query, after=after, limit=query.limit + 1, shared_ids=shared_ids + ) + if not candidates: + return ConsumerAppsPage(data=tuple(data), has_more=False, next_cursor=None) + visible = {app.id for app in self._visible(identity, candidates)} + for app in candidates: + if app.id in visible: + if len(data) == query.limit: + return ConsumerAppsPage(data=tuple(data), has_more=True, next_cursor=last) + data.append(app) + last = app.continuation + if len(candidates) < query.limit + 1: + return ConsumerAppsPage(data=tuple(data), has_more=False, next_cursor=None) + after = last + + def set_favorite(self, identity: ConsumerIdentity, app_id: str, *, enabled: bool) -> None: + if enabled: + apps = self._apps.by_ids(identity, (app_id,)) + if not self._visible(identity, apps): + raise ConsumerAppNotFoundError() + self._apps.favorite(identity, app_id, enabled=enabled) + + def record_usage(self, identity: ConsumerIdentity, *, tenant_id: str, app_id: str, used_at: datetime) -> None: + """Called only after a trusted Web execution request has been accepted.""" + self._apps.record_consumer_usage( + account_id=identity.account_id, + tenant_id=tenant_id, + app_id=app_id, + used_at=used_at, + ) + + def list_connections( + self, identity: ConsumerIdentity, *, provider: str | None, cursor: str | None, limit: int + ) -> ConsumerConnectionsPage: + rows = self._connections.account_connections(identity, provider=provider, after=cursor, limit=limit + 1) + data = self._with_apps(identity, rows[:limit]) + more = len(rows) > limit + return ConsumerConnectionsPage(data=data, has_more=more, next_cursor=data[-1].connection.id if more else None) + + def _with_apps( + self, identity: ConsumerIdentity, rows: Sequence[ConnectionSummary] + ) -> tuple[ConsumerConnection, ...]: + associations = self._connections.account_connection_apps(identity, tuple(row.id for row in rows)) + app_ids = tuple(dict.fromkeys(app_id for ids in associations.values() for app_id in ids)) + apps = {app.id: app for app in self._visible(identity, self._apps.by_ids(identity, app_ids))} + return tuple( + ConsumerConnection( + connection=row, apps=tuple(apps[id_] for id_ in associations.get(row.id, ()) if id_ in apps) + ) + for row in rows + ) + + def connection_context(self, identity: ConsumerIdentity, connection_id: str) -> AccountConnectionContext: + return self._connections.account_connection_context(identity, connection_id) + + def get_connection(self, identity: ConsumerIdentity, connection_id: str) -> ConsumerConnection: + context = self.connection_context(identity, connection_id) + summary = self._management.get_connection(context, connection_id) + return self._with_apps(identity, (summary,))[0] + + def update_connection( + self, identity: ConsumerIdentity, connection_id: str, command: UpdateConnectionInput + ) -> ConnectionSummary: + context = self.connection_context(identity, connection_id) + return self._management.update_connection(context, connection_id, command) + + def delete_connection(self, identity: ConsumerIdentity, connection_id: str) -> None: + self._management.delete_connection(self.connection_context(identity, connection_id), connection_id) + + def connection_revision(self, identity: ConsumerIdentity, connection_id: str) -> ConnectionRevisionView: + return self._management.get_connection_revision(self.connection_context(identity, connection_id), connection_id) + + def credential_schema( + self, identity: ConsumerIdentity, connection_id: str, *, connection_revision: str + ) -> tuple[ProviderConfig, ...]: + return self._management.get_owned_api_key_schema( + self.connection_context(identity, connection_id), + connection_id, + connection_revision=connection_revision, + ) + + def reauthorize( + self, + identity: ConsumerIdentity, + connection_id: str, + *, + connection_revision: str, + callback_url: str, + ) -> ConnectionOAuthStart: + return self._oauth.reauthorize_oauth( + self.connection_context(identity, connection_id), + connection_id, + connection_revision=connection_revision, + callback_url=callback_url, + ) diff --git a/api/services/end_user_connection_adapters.py b/api/services/end_user_connection_adapters.py index 5245855be4b43c..9bb07c4fdaa2b5 100644 --- a/api/services/end_user_connection_adapters.py +++ b/api/services/end_user_connection_adapters.py @@ -25,7 +25,11 @@ from models.provider_ids import ToolProviderID from services.end_user_connection_revision_adapters import PluginConnectionRevisions from services.end_user_connections.contracts import ConnectionTargetInput -from services.end_user_connections.entities import ToolConnectionTarget, WebConnectionContext +from services.end_user_connections.entities import ( + ConnectionManagementContext, + ToolConnectionTarget, + WebConnectionContext, +) from services.end_user_connections.errors import ( ConnectionCheckUnavailableError, ConnectionConfigurationError, @@ -168,7 +172,7 @@ def validate_format( def check( self, - context: WebConnectionContext, + context: ConnectionManagementContext, provider: str, credentials: Mapping[str, Any], *, @@ -181,7 +185,7 @@ def check( try: return self._auth.check_provider_credentials( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, provider=canonical, credentials=credentials, credential_type=CredentialType.API_KEY, diff --git a/api/services/end_user_connection_oauth_adapters.py b/api/services/end_user_connection_oauth_adapters.py index b54953a1025309..3011e2466ced05 100644 --- a/api/services/end_user_connection_oauth_adapters.py +++ b/api/services/end_user_connection_oauth_adapters.py @@ -37,7 +37,11 @@ from models.provider_ids import ToolProviderID from services.end_user_connection_adapters import PluginApiKeyCredentialGateway from services.end_user_connection_labels import mask_account_label -from services.end_user_connections.entities import ConnectionMutationGuard, ConnectionOAuthClient, WebConnectionContext +from services.end_user_connections.entities import ( + ConnectionManagementContext, + ConnectionMutationGuard, + ConnectionOAuthClient, +) from services.end_user_connections.errors import ( ConnectionCheckUnavailableError, ConnectionConfigurationError, @@ -79,7 +83,7 @@ class RedisConnectionOAuthAttempts: def save(self, attempt: ConnectionOAuthAttempt) -> str: provider = ToolProviderID(attempt.provider) context_id = OAuthProxyService.create_proxy_context( - user_id=attempt.context.end_user_id, + user_id=attempt.context.actor_id, tenant_id=attempt.context.tenant_id, plugin_id=provider.plugin_id, provider=provider.provider_name, @@ -111,7 +115,7 @@ def consume(self, context_id: str) -> ConnectionOAuthAttempt: attempt = self._adapter.validate_python(stored["attempt"]) provider = ToolProviderID(attempt.provider) if ( - stored["user_id"] != attempt.context.end_user_id + stored["user_id"] != attempt.context.actor_id or stored["tenant_id"] != attempt.context.tenant_id or stored["plugin_id"] != provider.plugin_id or stored["provider"] != provider.provider_name @@ -225,12 +229,14 @@ def redirect_uri(provider: str) -> str: # Keep the callback registered by existing workspace/platform OAuth Clients. return f"{dify_config.CONSOLE_API_URL}/console/api/oauth/plugin/{provider}/tool/callback" - def authorization_url(self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient) -> str: + def authorization_url( + self, context: ConnectionManagementContext, provider: str, client: ResolvedOAuthClient + ) -> str: identity = ToolProviderID(provider) try: return self._handler.get_authorization_url( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, plugin_id=identity.plugin_id, provider=identity.provider_name, redirect_uri=self.redirect_uri(provider), @@ -241,7 +247,7 @@ def authorization_url(self, context: WebConnectionContext, provider: str, client raise ConnectionOAuthError() from None def exchange( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, query_string: bytes + self, context: ConnectionManagementContext, provider: str, client: ResolvedOAuthClient, query_string: bytes ) -> ConnectionOAuthGrant: identity = ToolProviderID(provider) try: @@ -253,7 +259,7 @@ def exchange( callback = Request(builder.get_environ()) result = self._handler.get_credentials( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, plugin_id=identity.plugin_id, provider=identity.provider_name, redirect_uri=self.redirect_uri(provider), @@ -287,14 +293,18 @@ def _grant(result: PluginOAuthCredentialsResponse) -> ConnectionOAuthGrant: ) def refresh( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: ConnectionManagementContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> ConnectionOAuthGrant | CredentialCheckResult: identity = ToolProviderID(provider) try: with use_plugin_daemon_request_timeout(10): result = self._handler.refresh_credentials( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, plugin_id=identity.plugin_id, provider=identity.provider_name, redirect_uri=self.redirect_uri(provider), @@ -328,12 +338,16 @@ def refresh( ) def check( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: ConnectionManagementContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> CredentialCheckResult: try: return self._auth.check_provider_credentials( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, provider=provider, credentials=credentials, credential_type=CredentialType.OAUTH2, @@ -352,12 +366,16 @@ def check( ) def revoke( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: ConnectionManagementContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> OAuthRevokeResult: try: return self._auth.revoke_credentials( tenant_id=context.tenant_id, - user_id=context.end_user_id, + user_id=context.actor_id, provider=provider, system_credentials=client.parameters, plugin_unique_identifier=self._artifact(client), diff --git a/api/services/end_user_connection_revision_adapters.py b/api/services/end_user_connection_revision_adapters.py index 03440fb4452eec..bc0a2928fa6b8b 100644 --- a/api/services/end_user_connection_revision_adapters.py +++ b/api/services/end_user_connection_revision_adapters.py @@ -2,9 +2,14 @@ from collections.abc import Mapping from typing import Any, Protocol +from uuid import NAMESPACE_URL, uuid5 from constants.end_user_connections import OAuthClientSource -from services.end_user_connections.entities import WebConnectionContext +from services.end_user_connections.entities import ( + AccountConnectionContext, + ConnectionManagementContext, + WebConnectionContext, +) from services.end_user_connections.errors import ConnectionRevisionChangedError from services.end_user_connections.revision import ( ConnectionContractSnapshot, @@ -125,7 +130,7 @@ def _snapshot(context: WebConnectionContext, provider: ProviderContractSnapshot) ) def _current( - self, context: WebConnectionContext, provider: str, sources: set[OAuthClientSource] + self, context: ConnectionManagementContext, provider: str, sources: set[OAuthClientSource] ) -> ProviderContractSnapshot: return ProviderContractSnapshot( provider=provider, @@ -136,10 +141,36 @@ def _current( ) def resolve( - self, context: WebConnectionContext, provider: str, *, oauth_client_source: OAuthClientSource | None = None + self, + context: ConnectionManagementContext, + provider: str, + *, + oauth_client_source: OAuthClientSource | None = None, ) -> str: sources = {oauth_client_source} if oauth_client_source is not None else set() - return self._snapshots.save_revision(self._snapshot(context, self._current(context, provider, sources))) + current = self._current(context, provider, sources) + if isinstance(context, AccountConnectionContext): + return self._account_revision(context, current) + return self._snapshots.save_revision(self._snapshot(context, current)) + + @staticmethod + def _account_revision(context: AccountConnectionContext, snapshot: ProviderContractSnapshot) -> str: + # Management approves only the current Provider, never an App's consent. + # Content addressing needs no synthetic App or retained acceptance row. + return str( + uuid5( + NAMESPACE_URL, + ":".join( + ( + "account-connection", + context.account_id, + context.tenant_id, + context.connection_id, + snapshot.model_dump_json(), + ) + ), + ) + ) def _reference(self, context: WebConnectionContext, provider: str, revision: str) -> ProviderContractSnapshot: snapshot = self._snapshots.get_revision(context.tenant_id, context.app_id, revision) @@ -151,8 +182,10 @@ def _reference(self, context: WebConnectionContext, provider: str, revision: str return references[0] def acceptance( - self, context: WebConnectionContext, provider: str, revision: str, admitted: ProviderContractSnapshot + self, context: ConnectionManagementContext, provider: str, revision: str, admitted: ProviderContractSnapshot ) -> ConnectionRevisionAcceptance: + if isinstance(context, AccountConnectionContext): + return ConnectionRevisionAcceptance(revision, None) previous = self._reference(context, provider, revision) if admitted.provider != provider: raise ConnectionRevisionChangedError() @@ -163,12 +196,19 @@ def acceptance( def require( self, - context: WebConnectionContext, + context: ConnectionManagementContext, provider: str, revision: str, *, oauth_client_source: OAuthClientSource | None = None, ) -> ProviderContractSnapshot: + if isinstance(context, AccountConnectionContext): + sources = {oauth_client_source} if oauth_client_source is not None else set() + current = self._current(context, provider, sources) + canonical = self._account_revision(context, current) + if revision != canonical: + raise ConnectionRevisionChangedError(current_revision=canonical) + return current previous = self._reference(context, provider, revision) sources = {client.source for client in previous.oauth_clients} # A plugin-only API-key revision cannot approve an OAuth Client. diff --git a/api/services/end_user_connections/contracts.py b/api/services/end_user_connections/contracts.py index a306f22811e111..875a001496bbe0 100644 --- a/api/services/end_user_connections/contracts.py +++ b/api/services/end_user_connections/contracts.py @@ -31,7 +31,8 @@ class ConnectionInput(BaseModel): class ConnectionContractInput(ConnectionInput): connection_revision: ConnectionRevision = Field( description=( - "Opaque revision issued for the current Web App. Use App parameters for tool-node operations " + "Opaque revision issued for the current Web App or owned-Connection management context. " + "Use App parameters for tool-node operations " "or the owned Connection revision endpoint for Provider management." ), ) diff --git a/api/services/end_user_connections/entities.py b/api/services/end_user_connections/entities.py index 3abe41b149b6c7..e1e6f6a1f4e9aa 100644 --- a/api/services/end_user_connections/entities.py +++ b/api/services/end_user_connections/entities.py @@ -44,6 +44,38 @@ def owner(self) -> ConnectionOwner: return ConnectionOwner(account_id=self.account_id, end_user_id=None) return ConnectionOwner(account_id=None, end_user_id=self.end_user_id) + @property + def actor_id(self) -> str: + return self.end_user_id + + +@dataclass(frozen=True, slots=True) +class AccountConnectionContext: + """An owned Connection resolved from an authenticated Account, without an App. + + Repositories revalidate the Account and the exact tenant/Connection/owner chain + after asynchronous boundaries. This context never authorizes node execution. + """ + + tenant_id: str + account_id: str + connection_id: str + + def __post_init__(self) -> None: + if not self.tenant_id or not self.account_id or not self.connection_id: + raise ValueError("Incomplete account connection identity.") + + @property + def owner(self) -> ConnectionOwner: + return ConnectionOwner(account_id=self.account_id, end_user_id=None) + + @property + def actor_id(self) -> str: + return self.account_id + + +type ConnectionManagementContext = WebConnectionContext | AccountConnectionContext + @dataclass(frozen=True, slots=True) class ConnectionOAuthClient: diff --git a/api/services/end_user_connections/management.py b/api/services/end_user_connections/management.py index 6dd7c8783f2d7d..553454ded63ecb 100644 --- a/api/services/end_user_connections/management.py +++ b/api/services/end_user_connections/management.py @@ -20,7 +20,12 @@ ListConnectionsInput, UpdateConnectionInput, ) -from services.end_user_connections.entities import ConnectionAccessRecord, ConnectionMutationGuard, WebConnectionContext +from services.end_user_connections.entities import ( + ConnectionAccessRecord, + ConnectionManagementContext, + ConnectionMutationGuard, + WebConnectionContext, +) from services.end_user_connections.errors import ( ConnectionCheckUnavailableError, ConnectionConfigurationError, @@ -58,7 +63,7 @@ def list( self, context: WebConnectionContext, query: ListConnectionsInput, after_id: str | None ) -> ConnectionPage: ... - def find(self, context: WebConnectionContext, connection_id: str) -> StoredConnection | None: ... + def find(self, context: ConnectionManagementContext, connection_id: str) -> StoredConnection | None: ... def create_api_key( self, @@ -74,12 +79,12 @@ def create_api_key( ... def update( - self, context: WebConnectionContext, guard: ConnectionMutationGuard, changes: ConnectionChanges + self, context: ConnectionManagementContext, guard: ConnectionMutationGuard, changes: ConnectionChanges ) -> StoredConnection: """Revalidate identity and CAS; a no-change check still verifies the guard.""" ... - def delete(self, context: WebConnectionContext, guard: ConnectionMutationGuard) -> None: ... + def delete(self, context: ConnectionManagementContext, guard: ConnectionMutationGuard) -> None: ... class ConnectionCipher(Protocol): @@ -104,7 +109,7 @@ def validate_format( def check( self, - context: WebConnectionContext, + context: ConnectionManagementContext, provider: str, credentials: Mapping[str, Any], *, @@ -117,7 +122,7 @@ def check( self, context: WebConnectionContext, record: StoredConnection, *, plugin_unique_identifier: str ) -> ConnectionCheckOutcome: ... - def revoke_deleted(self, context: WebConnectionContext, record: StoredConnection) -> None: + def revoke_deleted(self, context: ConnectionManagementContext, record: StoredConnection) -> None: """Best effort after a successful conditional delete; never restore the record.""" ... @@ -148,10 +153,12 @@ def list_connections(self, context: WebConnectionContext, query: ListConnections raise ValueError("Invalid connection cursor.") from None return self._store.list(context, query, after_id) - def get_connection(self, context: WebConnectionContext, connection_id: str) -> ConnectionSummary: + def get_connection(self, context: ConnectionManagementContext, connection_id: str) -> ConnectionSummary: return self._load(context, connection_id).summary - def get_connection_revision(self, context: WebConnectionContext, connection_id: str) -> ConnectionRevisionView: + def get_connection_revision( + self, context: ConnectionManagementContext, connection_id: str + ) -> ConnectionRevisionView: record = self._load(context, connection_id) source = record.access.oauth_client.source if record.access.oauth_client is not None else None return ConnectionRevisionView( @@ -162,6 +169,16 @@ def get_connection_revision(self, context: WebConnectionContext, connection_id: ) ) + def get_owned_api_key_schema( + self, context: ConnectionManagementContext, connection_id: str, *, connection_revision: str + ) -> tuple[ProviderConfig, ...]: + record = self._load(context, connection_id) + self._require_api_key(record) + admitted = self._revisions.require(context, record.access.provider, connection_revision) + return self._credentials.get_schema( + context.tenant_id, record.access.provider, plugin_unique_identifier=admitted.plugin_unique_identifier + ) + def create_api_key(self, context: WebConnectionContext, command: CreateApiKeyConnectionInput) -> ConnectionSummary: require_personal_connection_access(context) target = self._targets.resolve(context, command) @@ -216,7 +233,7 @@ def get_api_key_schema( ) def update_connection( - self, context: WebConnectionContext, connection_id: str, command: UpdateConnectionInput + self, context: ConnectionManagementContext, connection_id: str, command: UpdateConnectionInput ) -> ConnectionSummary: record = self._load(context, connection_id) guard = connection_mutation_guard(context, record.access) @@ -260,7 +277,7 @@ def update_connection( ) return updated.summary - def delete_connection(self, context: WebConnectionContext, connection_id: str) -> None: + def delete_connection(self, context: ConnectionManagementContext, connection_id: str) -> None: record = self._load(context, connection_id) self._store.delete(context, connection_mutation_guard(context, record.access)) if record.access.credential_type == ConnectionCredentialType.OAUTH2: @@ -316,7 +333,7 @@ def check_connection_result( revision=current.access.revision, ) - def _load(self, context: WebConnectionContext, connection_id: str) -> StoredConnection: + def _load(self, context: ConnectionManagementContext, connection_id: str) -> StoredConnection: require_personal_connection_access(context) record = self._store.find(context, connection_id) if record is None or record.access.id != connection_id: diff --git a/api/services/end_user_connections/oauth.py b/api/services/end_user_connections/oauth.py index 6007616dd034b8..1b2f9f4f9dd513 100644 --- a/api/services/end_user_connections/oauth.py +++ b/api/services/end_user_connections/oauth.py @@ -16,7 +16,12 @@ ConnectionTargetResolver, StartConnectionOAuthInput, ) -from services.end_user_connections.entities import ConnectionMutationGuard, ConnectionOAuthClient, WebConnectionContext +from services.end_user_connections.entities import ( + ConnectionManagementContext, + ConnectionMutationGuard, + ConnectionOAuthClient, + WebConnectionContext, +) from services.end_user_connections.errors import ( ConnectionConfigurationError, ConnectionConflictError, @@ -61,7 +66,7 @@ class ResolvedOAuthClient: class ConnectionOAuthAttempt: """Continue the authenticated owner context without binding to a Site code.""" - context: WebConnectionContext + context: ConnectionManagementContext callback_url: str provider: str client: ConnectionOAuthClient @@ -98,15 +103,17 @@ def resolve_grant_client( def resolve_client(self, tenant_id: str, provider: str, source: OAuthClientSource) -> ResolvedOAuthClient: ... - def authorization_url(self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient) -> str: ... + def authorization_url( + self, context: ConnectionManagementContext, provider: str, client: ResolvedOAuthClient + ) -> str: ... def exchange( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, query_string: bytes + self, context: ConnectionManagementContext, provider: str, client: ResolvedOAuthClient, query_string: bytes ) -> ConnectionOAuthGrant: ... class ConnectionOAuthStore(Protocol): - def find(self, context: WebConnectionContext, connection_id: str) -> StoredConnection | None: ... + def find(self, context: ConnectionManagementContext, connection_id: str) -> StoredConnection | None: ... def validate_oauth_context(self, attempt: ConnectionOAuthAttempt) -> None: ... @@ -169,7 +176,7 @@ def start_oauth( ) def reauthorize_oauth( - self, context: WebConnectionContext, connection_id: str, *, callback_url: str, connection_revision: str + self, context: ConnectionManagementContext, connection_id: str, *, callback_url: str, connection_revision: str ) -> ConnectionOAuthStart: record = self._load(context, connection_id) reference = record.access.oauth_client @@ -192,7 +199,7 @@ def reauthorize_oauth( def _start( self, - context: WebConnectionContext, + context: ConnectionManagementContext, provider: str, client: ResolvedOAuthClient, *, @@ -267,7 +274,7 @@ def complete_oauth(self, attempt: ConnectionOAuthAttempt, *, query_string: bytes if connection_mutation_guard(context, record.access) != attempt.guard: raise ConnectionConflictError() else: - if attempt.target is None: + if attempt.target is None or not isinstance(context, WebConnectionContext): raise ConnectionOAuthError() target = self._targets.resolve(context, attempt.target) if target.provider != attempt.provider or target.oauth_client_source != attempt.client.source: @@ -299,6 +306,8 @@ def complete_oauth(self, attempt: ConnectionOAuthAttempt, *, query_string: bytes context, attempt.provider, revision, oauth_client_source=attempt.client.source ) if attempt.target is not None: + if not isinstance(context, WebConnectionContext): + raise ConnectionOAuthError() target = self._targets.resolve(context, attempt.target) attempt = replace(attempt, target_workflow_id=target.workflow_id) encrypted = self._cipher.encrypt(context.tenant_id, grant.credentials) @@ -310,7 +319,7 @@ def complete_oauth(self, attempt: ConnectionOAuthAttempt, *, query_string: bytes revision_acceptance=self._revisions.acceptance(context, attempt.provider, revision, admitted), ) - def _load(self, context: WebConnectionContext, connection_id: str) -> StoredConnection: + def _load(self, context: ConnectionManagementContext, connection_id: str) -> StoredConnection: require_personal_connection_access(context) record = self._store.find(context, connection_id) if record is None or record.access.id != connection_id: diff --git a/api/services/end_user_connections/oauth_lifecycle.py b/api/services/end_user_connections/oauth_lifecycle.py index dc58190b691e0f..399416fc96735c 100644 --- a/api/services/end_user_connections/oauth_lifecycle.py +++ b/api/services/end_user_connections/oauth_lifecycle.py @@ -9,7 +9,12 @@ from clients.plugin.auth_entities import CredentialCheckResult, OAuthRevokeResult from constants.end_user_connections import ConnectionCredentialStatus, OAuthClientSource from services.end_user_connections.contracts import ConnectionCheckOutcome, ConnectionCheckView -from services.end_user_connections.entities import ConnectionMutationGuard, ConnectionOAuthClient, WebConnectionContext +from services.end_user_connections.entities import ( + ConnectionManagementContext, + ConnectionMutationGuard, + ConnectionOAuthClient, + WebConnectionContext, +) from services.end_user_connections.errors import ( ConnectionConflictError, ConnectionNotFoundError, @@ -31,15 +36,27 @@ def resolve_grant_client( ) -> ResolvedOAuthClient: ... def refresh( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: WebConnectionContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> ConnectionOAuthGrant | CredentialCheckResult: ... def check( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: WebConnectionContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> CredentialCheckResult: ... def revoke( - self, context: WebConnectionContext, provider: str, client: ResolvedOAuthClient, credentials: Mapping[str, Any] + self, + context: ConnectionManagementContext, + provider: str, + client: ResolvedOAuthClient, + credentials: Mapping[str, Any], ) -> OAuthRevokeResult: ... @@ -254,7 +271,7 @@ def _persist( client=client.snapshot, ) - def revoke_deleted(self, context: WebConnectionContext, record: StoredConnection) -> None: + def revoke_deleted(self, context: ConnectionManagementContext, record: StoredConnection) -> None: reference = record.access.oauth_client if ( reference is None diff --git a/api/services/end_user_connections/policy.py b/api/services/end_user_connections/policy.py index 14b14d6235921f..4a82f469f28a21 100644 --- a/api/services/end_user_connections/policy.py +++ b/api/services/end_user_connections/policy.py @@ -4,6 +4,7 @@ from constants.end_user_connections import ConnectionCredentialType from services.end_user_connections.entities import ( ConnectionAccessRecord, + ConnectionManagementContext, ConnectionMutationGuard, ToolConnectionTarget, WebConnectionContext, @@ -17,13 +18,13 @@ ) -def require_personal_connection_access(context: WebConnectionContext) -> None: +def require_personal_connection_access(context: ConnectionManagementContext) -> None: if context.account_id is None and not dify_config.END_USER_AUTH_ALLOW_ANONYMOUS: raise ConnectionAccountRequiredError() def require_owned_connection( - context: WebConnectionContext, record: ConnectionAccessRecord | None + context: ConnectionManagementContext, record: ConnectionAccessRecord | None ) -> ConnectionAccessRecord: """Ownership allows management even when a node, Provider, or Client changed. @@ -106,7 +107,7 @@ def require_applicable_connection( def connection_mutation_guard( - context: WebConnectionContext, record: ConnectionAccessRecord | None + context: ConnectionManagementContext, record: ConnectionAccessRecord | None ) -> ConnectionMutationGuard: owned = require_owned_connection(context, record) return ConnectionMutationGuard( diff --git a/api/services/end_user_connections/revision.py b/api/services/end_user_connections/revision.py index 263b329e490cd0..0bcd46e1869a0d 100644 --- a/api/services/end_user_connections/revision.py +++ b/api/services/end_user_connections/revision.py @@ -11,7 +11,7 @@ from constants.end_user_connections import ConnectionRevisionAcceptanceReason, OAuthClientSource from core.tools.entities.common_entities import I18nObject from libs.helper import UUIDStr -from services.end_user_connections.entities import WebConnectionContext +from services.end_user_connections.entities import ConnectionManagementContext ConnectionRevision = UUIDStr @@ -113,12 +113,16 @@ def requires_reauthorization( class ConnectionProviderRevisions(Protocol): def resolve( - self, context: WebConnectionContext, provider: str, *, oauth_client_source: OAuthClientSource | None = None + self, + context: ConnectionManagementContext, + provider: str, + *, + oauth_client_source: OAuthClientSource | None = None, ) -> str: ... def require( self, - context: WebConnectionContext, + context: ConnectionManagementContext, provider: str, revision: str, *, @@ -126,5 +130,5 @@ def require( ) -> ProviderContractSnapshot: ... def acceptance( - self, context: WebConnectionContext, provider: str, revision: str, admitted: ProviderContractSnapshot + self, context: ConnectionManagementContext, provider: str, revision: str, admitted: ProviderContractSnapshot ) -> ConnectionRevisionAcceptance: ... diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index 0587e7eb6b21e6..e17e652d5b8055 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -43,6 +43,7 @@ WorkflowSchedulePlan, ) from models.agent import WorkflowAgentNodeBinding +from models.consumer import AccountAppUsage from models.tools import WorkflowToolProvider from models.trigger import WorkflowPluginTrigger, WorkflowTriggerLog, WorkflowWebhookTrigger from models.web import PinnedConversation, SavedMessage @@ -126,6 +127,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_app_resource_access_token_relations(tenant_id, app_id) _delete_installed_apps(tenant_id, app_id) _delete_app_stars(tenant_id, app_id) + _delete_consumer_app_usage(tenant_id, app_id) _delete_recommended_apps(tenant_id, app_id) _delete_app_annotation_data(tenant_id, app_id) _delete_app_dataset_joins(tenant_id, app_id) @@ -160,6 +162,16 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): raise self.retry(exc=e, countdown=60) from e # Retry after 60 seconds +def _delete_consumer_app_usage(tenant_id: str, app_id: str) -> None: + with session_factory.create_session() as session, session.begin(): + session.execute( + delete(AccountAppUsage).where( + AccountAppUsage.tenant_id == tenant_id, + AccountAppUsage.app_id == app_id, + ) + ) + + def _delete_app_model_configs(tenant_id: str, app_id: str): def del_model_config(session: Session, model_config_id: str): session.execute( diff --git a/api/tests/helpers/consumer.py b/api/tests/helpers/consumer.py new file mode 100644 index 00000000000000..0815f19751c5f0 --- /dev/null +++ b/api/tests/helpers/consumer.py @@ -0,0 +1,77 @@ +"""Real Account consumer services with deterministic plugin and access boundaries.""" + +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy.orm import Session, sessionmaker + +from libs.passport import PassportService +from repositories.account.repository import SQLAlchemyAccountRepository +from repositories.app.console_repository import ConsoleAppRepository +from services.consumer_service import ConsumerIdentity, ConsumerService +from services.web_identity_service import WebIdentityService +from services.web_passport_gateways import PassportTokenGateway +from tests.helpers.end_user_oauth import OAuthHarness, make_oauth_harness +from tests.helpers.unit.config_override import apply_config_overrides + + +@dataclass +class ConsumerAccess: + denied: set[str] = field(default_factory=set) + + def get_visible_app_ids(self, *, user_id: str, app_ids: Sequence[str]) -> frozenset[str]: + return frozenset(app_id for app_id in app_ids if app_id not in self.denied) + + +@dataclass +class ConsumerHarness: + oauth: OAuthHarness + apps: ConsoleAppRepository + access: ConsumerAccess + service: ConsumerService + identity: ConsumerIdentity + + def token(self, *, source: str | None = None, subject: str = "Console API Passport") -> str: + return PassportService().issue( + { + "sub": subject, + "user_id": self.identity.account_id, + "token_source": source, + "exp": datetime.now(UTC) + timedelta(minutes=10), + } + ) + + +def make_consumer_harness(sessions: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> ConsumerHarness: + apply_config_overrides( + monkeypatch, + SECRET_KEY="consumer-test-key-with-at-least-32-bytes", + CONSOLE_API_URL="http://localhost", + CONSOLE_WEB_URL="http://localhost", + ) + oauth = make_oauth_harness(sessions, monkeypatch) + assert oauth.base.context.account_id is not None + apps = ConsoleAppRepository(session_factory=sessions) + access = ConsumerAccess() + return ConsumerHarness( + oauth, + apps, + access, + ConsumerService( + apps=apps, + connections=oauth.base.repository, + access=access, + shared_apps=lambda: (oauth.base.context.app_id,), + management=oauth.base.service, + oauth=oauth.service, + identities=WebIdentityService( + accounts=SQLAlchemyAccountRepository(session_factory=sessions), + allow_console_login=True, + allow_webapp_login=False, + ), + tokens=PassportTokenGateway(passport=PassportService()), + ), + ConsumerIdentity(oauth.base.context.account_id), + ) diff --git a/api/tests/unit_tests/controllers/console/test_me.py b/api/tests/unit_tests/controllers/console/test_me.py new file mode 100644 index 00000000000000..5551fe838447e6 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_me.py @@ -0,0 +1,140 @@ +"""Serve consumer HTTP requests with real authentication, persistence and serializers.""" + +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from uuid import uuid4 + +import pytest +from flask import Flask +from flask.testing import FlaskClient +from sqlalchemy.orm import Session, sessionmaker + +from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN +from controllers.console import bp, me +from libs.passport import PassportService +from tests.helpers.consumer import ConsumerHarness, make_consumer_harness + + +@pytest.fixture +def harness(sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> ConsumerHarness: + return make_consumer_harness(sqlite_session_factory, monkeypatch) + + +@pytest.fixture +def client(harness: ConsumerHarness, monkeypatch: pytest.MonkeyPatch) -> Iterator[FlaskClient]: + services = SimpleNamespace(consumer=harness.service, end_user_connection_oauth=harness.oauth.service) + monkeypatch.setattr(me, "application_services", lambda: services) + from controllers.common import end_user_connection_oauth + + monkeypatch.setattr(end_user_connection_oauth, "application_services", lambda: services) + app = Flask(__name__) + app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False) + app.register_blueprint(bp) + with app.test_client() as client: + client.set_cookie(COOKIE_NAME_ACCESS_TOKEN, harness.token()) + csrf = PassportService().issue( + { + "sub": harness.identity.account_id, + "exp": datetime.now(UTC) + timedelta(minutes=10), + } + ) + client.set_cookie(COOKIE_NAME_CSRF_TOKEN, csrf) + client.environ_base["HTTP_" + HEADER_NAME_CSRF_TOKEN.upper().replace("-", "_")] = csrf + yield client + + +def test_http_app_views_favorites_and_timestamps(client: FlaskClient, harness: ConsumerHarness) -> None: + base = harness.oauth.base + path = f"/console/api/me/apps/{base.context.app_id}/favorite" + assert client.put(path).status_code == 204 + assert client.put(path).status_code == 204 + harness.service.record_usage( + harness.identity, tenant_id=base.context.tenant_id, app_id=base.context.app_id, used_at=datetime(2026, 10, 30) + ) + for view in ("recent", "favorites", "shared"): + response = client.get(f"/console/api/me/apps?view={view}&limit=1") + assert response.status_code == 200 + data = response.get_json() + assert data["has_more"] is False + assert data["data"][0]["id"] == base.context.app_id + assert isinstance(data["data"][0]["last_used_at"], int) + assert "continuation" not in data["data"][0] + assert client.delete(path).status_code == 204 + assert client.get("/console/api/me/apps?view=favorites").get_json()["data"] == [] + + +def test_http_connection_replacement_and_delete(client: FlaskClient, harness: ConsumerHarness) -> None: + base = harness.oauth.base + created = base.service.create_api_key(base.context, base.command()) + path = f"/console/api/me/connections/{created.id}" + listed = client.get("/console/api/me/connections?limit=1") + assert listed.status_code == 200 + assert listed.get_json()["data"][0]["connection"]["id"] == created.id + response = client.get(path) + assert response.status_code == 200 + assert isinstance(response.get_json()["connection"]["created_at"], int) + revision = client.get(path + "/revision").get_json()["connection_revision"] + schema = client.get(path + "/credential-schema", query_string={"connection_revision": revision}) + assert schema.status_code == 200 + assert schema.get_json()[0]["name"] == "api_key" + updated = client.patch( + path, + json={"name": "Renamed", "credentials": {"api_key": "replacement-secret"}, "connection_revision": revision}, + ) + assert updated.status_code == 200 + assert updated.get_json()["name"] == "Renamed" + assert "replacement-secret" not in updated.get_data(as_text=True) + assert "encrypted_credentials" not in response.get_data(as_text=True) + assert client.delete(path).status_code == 204 + assert client.get(path).status_code == 404 + + +@pytest.mark.parametrize("suffix", ["", "/revision", "/credential-schema"]) +def test_foreign_connection_matches_unknown_http_id(client: FlaskClient, suffix: str) -> None: + response = client.get(f"/console/api/me/connections/{uuid4()}{suffix}") + # Schema parsing precedes the ownership lookup only when its required revision is absent. + assert response.status_code == (400 if suffix == "/credential-schema" else 404) + assert response.get_json()["code"] == ( + "invalid_param" if suffix == "/credential-schema" else "connection_not_found" + ) + + +@pytest.mark.parametrize( + "payload", [{"name": None}, {"credentials": {"api_key": "secret"}}, {"account_id": "secret"}, {}] +) +def test_invalid_connection_payloads_are_safe( + client: FlaskClient, harness: ConsumerHarness, payload: dict[str, object] +) -> None: + base = harness.oauth.base + created = base.service.create_api_key(base.context, base.command()) + response = client.patch(f"/console/api/me/connections/{created.id}", json=payload) + assert response.status_code == 400 + assert response.get_json()["code"] == "invalid_param" + assert "secret" not in response.get_data(as_text=True) + + +def test_account_cookie_and_csrf_are_both_required(client: FlaskClient, harness: ConsumerHarness) -> None: + client.delete_cookie(COOKIE_NAME_ACCESS_TOKEN) + assert client.get("/console/api/me/apps").status_code == 401 + client.set_cookie(COOKIE_NAME_ACCESS_TOKEN, harness.token()) + client.delete_cookie(COOKIE_NAME_CSRF_TOKEN) + assert client.get("/console/api/me/apps").status_code == 401 + + +def test_oauth_start_and_callback_use_account_origin(client: FlaskClient, harness: ConsumerHarness) -> None: + oauth = harness.oauth + created = oauth.finish(oauth.start()) + path = f"/console/api/me/connections/{created.id}" + revision = client.get(path + "/revision").get_json()["connection_revision"] + response = client.post(path + "/reauthorize", json={"connection_revision": revision}) + assert response.status_code == 200 + assert "HttpOnly" in response.headers["Set-Cookie"] + from urllib.parse import parse_qs, urlsplit + + state = parse_qs(urlsplit(response.get_json()["authorization_url"]).query)["state"][0] + callback = f"/console/api/me/connections/oauth/{created.provider}/callback?state={state}&code=code" + completed = client.get(callback) + assert completed.status_code == 302 + assert completed.headers["Location"] == f"http://localhost/oauth-callback?connection_id={created.id}" + assert client.get(callback).status_code == 400 diff --git a/api/tests/unit_tests/migrations/test_2026_10_30_1600-b73e9c40a215_add_account_app_usage.py b/api/tests/unit_tests/migrations/test_2026_10_30_1600-b73e9c40a215_add_account_app_usage.py new file mode 100644 index 00000000000000..7f01cc63521f07 --- /dev/null +++ b/api/tests/unit_tests/migrations/test_2026_10_30_1600-b73e9c40a215_add_account_app_usage.py @@ -0,0 +1,41 @@ +"""Exercise the portable usage migration against a real database schema.""" + +from importlib import import_module + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def test_usage_schema_upgrade_and_downgrade() -> None: + migration = import_module("migrations.versions.2026_10_30_1600-b73e9c40a215_add_account_app_usage") + engine = sa.create_engine("sqlite://") + try: + with engine.begin() as connection: + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + inspector = sa.inspect(connection) + assert {column["name"] for column in inspector.get_columns("account_app_usages")} == { + "id", + "account_id", + "tenant_id", + "app_id", + "last_used_at", + "created_at", + "updated_at", + } + assert inspector.get_unique_constraints("account_app_usages")[0]["column_names"] == [ + "account_id", + "tenant_id", + "app_id", + ] + assert inspector.get_indexes("account_app_usages")[0]["column_names"] == [ + "account_id", + "last_used_at", + "app_id", + ] + with Operations.context(MigrationContext.configure(connection)): + migration.downgrade() + assert not sa.inspect(connection).has_table("account_app_usages") + finally: + engine.dispose() diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 4b95a72f8df8ba..18e47a06ff30dd 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -8,8 +8,9 @@ from unittest.mock import MagicMock import pytest +import sqlalchemy as sa from pytest_mock import MockerFixture -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker import services.app_generate_service as ags_module from core.app.apps.message_based_app_generator import MessageBasedAppGenerator @@ -17,8 +18,9 @@ from core.app.entities.app_invoke_entities import InvokeFrom from enums import DeploymentEdition, QuotaType from models.account import Account +from models.consumer import AccountAppUsage from models.enums import AppStatus -from models.model import App, AppMode +from models.model import App, AppMode, EndUser from models.workflow import Workflow, WorkflowType from services.app_generate_service import AppGenerateService from services.errors.app import ( @@ -27,6 +29,7 @@ WorkflowNotFoundError, ) from tasks.app_generate.workflow_execute_task import AppExecutionParams +from tests.helpers.consumer import make_consumer_harness from tests.helpers.unit.config_override import apply_config_overrides """ @@ -1299,3 +1302,63 @@ def start_task(): # Verify publish happened and consumer received in order assert published_order == ["workflow_started", "workflow_finished"] assert [m.get("event") for m in received] == ["workflow_started", "workflow_finished"] + + +@pytest.mark.parametrize("rejected", [False, True]) +@pytest.mark.parametrize("guest", [False, True]) +def test_personal_usage_tracks_only_accepted_account_web_runs( + sqlite_session_factory: sessionmaker[Session], + monkeypatch: pytest.MonkeyPatch, + config_overrides: Callable[..., None], + rejected: bool, + guest: bool, +) -> None: + from types import SimpleNamespace + + from extensions import ext_application_services + from services.workflow_service import WorkflowService + + harness = make_consumer_harness(sqlite_session_factory, monkeypatch) + base = harness.oauth.base + workflows = WorkflowService(session_maker=base.sessions) + monkeypatch.setattr(ags_module, "WorkflowService", lambda: workflows) + config_overrides( + DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, + APP_DEFAULT_ACTIVE_REQUESTS=0, + APP_MAX_ACTIVE_REQUESTS=0, + PUBSUB_REDIS_CHANNEL_TYPE="streams", + ) + monkeypatch.setattr( + ext_application_services, "application_services", lambda: SimpleNamespace(consumer=harness.service) + ) + monkeypatch.setattr( + MessageBasedAppGenerator, + "retrieve_events", + classmethod(lambda _cls, *_args, **_kwargs: iter(())), + ) + with base.sessions.begin() as session: + workflow = session.get(Workflow, base.workflow_id) + assert workflow is not None + workflow.graph = json.dumps({"nodes": [], "edges": []}) + app = session.get(App, base.context.app_id) + assert app is not None + if rejected: + app.workflow_id = None + if guest: + user = session.get(EndUser, base.context.end_user_id) + assert user is not None + user.account_id = None + with base.sessions() as session: + app = session.get(App, base.context.app_id) + user = session.get(EndUser, base.context.end_user_id) + assert app is not None + assert user is not None + if rejected: + with pytest.raises(ValueError, match="not published"): + AppGenerateService.generate(app, user, {"inputs": {}}, InvokeFrom.WEB_APP, session=session) + else: + response = AppGenerateService.generate(app, user, {"inputs": {}}, InvokeFrom.WEB_APP, session=session) + assert list(response) == [] + with base.sessions() as session: + count = session.scalar(sa.select(sa.func.count()).select_from(AccountAppUsage)) + assert count == (1 if not rejected and not guest else 0) diff --git a/api/tests/unit_tests/services/test_consumer_adapters.py b/api/tests/unit_tests/services/test_consumer_adapters.py new file mode 100644 index 00000000000000..56fc1d4edf0c52 --- /dev/null +++ b/api/tests/unit_tests/services/test_consumer_adapters.py @@ -0,0 +1,39 @@ +"""Shared discovery uses the existing deployment contract and fails closed.""" + +import pytest + +from services.consumer_adapters import SharedConsumerAppDiscovery +from services.enterprise.enterprise_service import EnterpriseService +from services.errors.enterprise import EnterpriseAPIError +from services.webapp_access_query_service import WebAppAccessUnavailableError + + +def test_shared_discovery_consumes_all_pages_and_deduplicates(monkeypatch: pytest.MonkeyPatch) -> None: + def list_apps(*, page: int, limit: int) -> dict[str, object]: + assert limit == 100 + if page == 1: + return {"data": [{"appId": "app-1"}], "hasMore": True} + assert page == 2 + return {"data": [{"appId": "app-1"}, {"appId": "app-2"}], "hasMore": False} + + monkeypatch.setattr(EnterpriseService.WebAppAuth, "list_externally_accessible_apps", list_apps) + assert SharedConsumerAppDiscovery(enabled=True)() == ("app-1", "app-2") + assert SharedConsumerAppDiscovery(enabled=False)() == () + + +@pytest.mark.parametrize("result", [{}, {"data": [], "hasMore": True}, {"data": [], "hasMore": "false"}]) +def test_invalid_shared_discovery_response_is_unavailable( + monkeypatch: pytest.MonkeyPatch, result: dict[str, object] +) -> None: + monkeypatch.setattr(EnterpriseService.WebAppAuth, "list_externally_accessible_apps", lambda **_kwargs: result) + with pytest.raises(WebAppAccessUnavailableError): + SharedConsumerAppDiscovery(enabled=True)() + + +def test_discovery_outage_is_unavailable(monkeypatch: pytest.MonkeyPatch) -> None: + def unavailable(**_kwargs) -> dict[str, object]: + raise EnterpriseAPIError("Unavailable", status_code=503) + + monkeypatch.setattr(EnterpriseService.WebAppAuth, "list_externally_accessible_apps", unavailable) + with pytest.raises(WebAppAccessUnavailableError): + SharedConsumerAppDiscovery(enabled=True)() diff --git a/api/tests/unit_tests/services/test_consumer_service.py b/api/tests/unit_tests/services/test_consumer_service.py new file mode 100644 index 00000000000000..26deef0c350f07 --- /dev/null +++ b/api/tests/unit_tests/services/test_consumer_service.py @@ -0,0 +1,249 @@ +"""Consumer identity, ownership, usage and access-policy behavior with real persistence.""" + +from dataclasses import replace +from datetime import datetime, timedelta +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from sqlalchemy.orm import Session, sessionmaker + +from models.account import Account, AccountStatus, TenantAccountJoin +from models.consumer import AccountAppUsage +from models.end_user_connection import AppConnection, EndUserConnection +from models.enums import CustomizeTokenStrategy +from models.model import App, AppStar, EndUser, Site +from services.consumer_service import ConsumerAppNotFoundError, ConsumerAppsQuery, ConsumerIdentity +from services.end_user_connections.contracts import UpdateConnectionInput +from services.end_user_connections.entities import AccountConnectionContext +from services.end_user_connections.errors import ConnectionConflictError, ConnectionNotFoundError +from services.web_identity_service import WebIdentityError +from tests.helpers.consumer import ConsumerHarness, make_consumer_harness +from tests.helpers.end_user_api_key import make_harness +from tests.helpers.end_user_connection_contract import new_connection + + +@pytest.fixture +def harness(sqlite_session_factory: sessionmaker[Session], monkeypatch: pytest.MonkeyPatch) -> ConsumerHarness: + return make_consumer_harness(sqlite_session_factory, monkeypatch) + + +def test_account_authentication_does_not_provision_workspace(harness: ConsumerHarness) -> None: + assert harness.service.authenticate(harness.token()) == harness.identity + with harness.oauth.base.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(TenantAccountJoin)) == 0 + + +@pytest.mark.parametrize("source", ["webapp", "webapp_account", "webapp_login_token", "unknown"]) +def test_app_tokens_cannot_become_consumer_identity(harness: ConsumerHarness, source: str) -> None: + with pytest.raises(WebIdentityError): + harness.service.authenticate(harness.token(source=source)) + + +def test_banned_account_cannot_authenticate_or_continue_connection_management(harness: ConsumerHarness) -> None: + base = harness.oauth.base + connection = base.service.create_api_key(base.context, base.command()) + context = harness.service.connection_context(harness.identity, connection.id) + with base.sessions.begin() as session: + session.execute( + sa.update(Account).where(Account.id == harness.identity.account_id).values(status=AccountStatus.BANNED) + ) + with pytest.raises(WebIdentityError): + harness.service.authenticate(harness.token()) + from services.end_user_connections.errors import ConnectionIdentityError + + with pytest.raises(ConnectionIdentityError): + base.service.delete_connection(context, connection.id) + + +def test_favorites_and_recent_are_account_scoped_and_recheck_access(harness: ConsumerHarness) -> None: + base = harness.oauth.base + app_id = base.context.app_id + for _ in range(2): + harness.service.set_favorite(harness.identity, app_id, enabled=True) + now = datetime(2026, 10, 30, 10) + for moment in (now, now - timedelta(days=1), now + timedelta(hours=1)): + harness.service.record_usage(harness.identity, tenant_id=base.context.tenant_id, app_id=app_id, used_at=moment) + for view in ("recent", "favorites", "shared"): + result = harness.service.list_apps(harness.identity, ConsumerAppsQuery(view=view)) + assert [app.id for app in result.data] == [app_id] + assert result.data[0].is_favorite + assert result.data[0].last_used_at == now + timedelta(hours=1) + other = ConsumerIdentity(str(uuid4())) + assert not harness.service.list_apps(other, ConsumerAppsQuery()).data + assert not harness.service.list_apps(other, ConsumerAppsQuery(view="favorites")).data + with base.sessions() as session: + assert session.scalar(sa.select(sa.func.count()).select_from(AppStar)) == 1 + assert session.scalar(sa.select(sa.func.count()).select_from(AccountAppUsage)) == 1 + harness.access.denied.add(app_id) + assert not harness.service.list_apps(harness.identity, ConsumerAppsQuery(view="favorites")).data + with pytest.raises(ConsumerAppNotFoundError): + harness.service.set_favorite(harness.identity, app_id, enabled=True) + for _ in range(2): + harness.service.set_favorite(harness.identity, app_id, enabled=False) + + +def test_unpublished_app_cannot_be_favorited_or_listed(harness: ConsumerHarness) -> None: + base = harness.oauth.base + with base.sessions.begin() as session: + session.execute(sa.update(App).where(App.id == base.context.app_id).values(enable_site=False)) + with pytest.raises(ConsumerAppNotFoundError): + harness.service.set_favorite(harness.identity, base.context.app_id, enabled=True) + assert not harness.service.list_apps(harness.identity, ConsumerAppsQuery(view="shared")).data + + +def test_connection_queries_do_not_decrypt_or_expose_foreign_and_guest_credentials(harness: ConsumerHarness) -> None: + base = harness.oauth.base + owned = base.service.create_api_key(base.context, base.command()) + foreign = replace(new_connection(), tenant_id=base.context.tenant_id, account_id=str(uuid4())) + guest = replace(new_connection(), tenant_id=base.context.tenant_id, account_id=None, end_user_id=str(uuid4())) + with base.sessions.begin() as session: + session.add_all([foreign, guest]) + session.add( + AppConnection( + tenant_id=base.context.tenant_id, + app_id=base.context.app_id, + workflow_id=base.workflow_id, + node_id="tool-1", + end_user_id=base.context.end_user_id, + provider=owned.provider, + connection_id=owned.id, + ) + ) + result = harness.service.list_connections(harness.identity, provider=None, cursor=None, limit=20) + assert [entry.connection.id for entry in result.data] == [owned.id] + assert [app.id for app in result.data[0].apps] == [base.context.app_id] + assert "original-secret" not in result.model_dump_json() + for id_ in (foreign.id, guest.id, str(uuid4())): + with pytest.raises(ConnectionNotFoundError): + harness.service.get_connection(harness.identity, id_) + harness.access.denied.add(base.context.app_id) + assert harness.service.get_connection(harness.identity, owned.id).apps == () + + +def test_orphan_connection_can_be_repaired_and_deleted_without_app(harness: ConsumerHarness) -> None: + base = harness.oauth.base + owned = base.service.create_api_key(base.context, base.command()) + context = harness.service.connection_context(harness.identity, owned.id) + assert isinstance(context, AccountConnectionContext) + revision = harness.service.connection_revision(harness.identity, owned.id).connection_revision + with base.sessions.begin() as session: + session.execute(sa.delete(EndUser).where(EndUser.id == base.context.end_user_id)) + session.execute(sa.delete(App).where(App.id == base.context.app_id)) + assert harness.service.get_connection(harness.identity, owned.id).connection.id == owned.id + updated = harness.service.update_connection( + harness.identity, + owned.id, + UpdateConnectionInput( + name="Repaired", + credentials={"api_key": "replacement-secret"}, + connection_revision=revision, + ), + ) + assert updated.name == "Repaired" + harness.service.delete_connection(harness.identity, owned.id) + with base.sessions() as session: + assert session.get(EndUserConnection, owned.id) is None + + +def test_changed_owner_during_external_check_cannot_overwrite_credentials(harness: ConsumerHarness) -> None: + base = harness.oauth.base + owned = base.service.create_api_key(base.context, base.command()) + revision = harness.service.connection_revision(harness.identity, owned.id).connection_revision + + def change_owner() -> None: + with base.sessions.begin() as session: + session.execute( + sa.update(EndUserConnection).where(EndUserConnection.id == owned.id).values(account_id=str(uuid4())) + ) + + base.auth.on_check = change_owner + with pytest.raises((ConnectionNotFoundError, ConnectionConflictError)): + harness.service.update_connection( + harness.identity, + owned.id, + UpdateConnectionInput( + credentials={"api_key": "uncommitted-secret"}, + connection_revision=revision, + ), + ) + with base.sessions() as session: + row = session.get(EndUserConnection, owned.id) + assert row is not None + assert "uncommitted-secret" not in row.encrypted_credentials + + +def test_account_oauth_reauthorization_survives_app_deletion_and_rejects_replay(harness: ConsumerHarness) -> None: + oauth = harness.oauth + base = oauth.base + created = oauth.finish(oauth.start()) + revision = harness.service.connection_revision(harness.identity, created.id).connection_revision + start = harness.service.reauthorize( + harness.identity, + created.id, + connection_revision=revision, + callback_url="https://console.example/me/oauth", + ) + with base.sessions.begin() as session: + session.execute(sa.delete(App).where(App.id == base.context.app_id)) + session.execute(sa.delete(EndUser).where(EndUser.id == base.context.end_user_id)) + reauthorized = oauth.finish(start) + assert reauthorized.id == created.id + from services.end_user_connections.errors import ConnectionOAuthError + + with pytest.raises(ConnectionOAuthError): + oauth.finish(start) + harness.service.delete_connection(harness.identity, created.id) + assert base.auth.revocations + + +def test_recent_pagination_fills_pages_after_denials_and_handles_equal_timestamps(harness: ConsumerHarness) -> None: + base = harness.oauth.base + app_ids = [base.context.app_id] + for _ in range(5): + other = make_harness(base.sessions) + app_ids.append(other.context.app_id) + with base.sessions.begin() as session: + session.add( + Site( + app_id=other.context.app_id, + title="Another App", + default_language="en-US", + customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW, + code=str(uuid4()), + ) + ) + moment = datetime(2026, 10, 30) + for app_id in app_ids: + with base.sessions() as session: + app = session.get(App, app_id) + assert app is not None + tenant_id = app.tenant_id + harness.service.record_usage(harness.identity, tenant_id=tenant_id, app_id=app_id, used_at=moment) + app_ids.sort() + harness.access.denied.update(app_ids[:2]) + first = harness.service.list_apps(harness.identity, ConsumerAppsQuery(limit=2)) + assert [app.id for app in first.data] == app_ids[2:4] + assert first.has_more + second = harness.service.list_apps(harness.identity, ConsumerAppsQuery(limit=2, cursor=first.next_cursor)) + assert [app.id for app in second.data] == app_ids[4:] + assert not second.has_more + assert second.next_cursor is None + with pytest.raises(ValueError, match="cursor"): + harness.service.list_apps(ConsumerIdentity(str(uuid4())), ConsumerAppsQuery(cursor=first.next_cursor)) + with pytest.raises(ValueError, match="cursor"): + harness.service.list_apps(harness.identity, ConsumerAppsQuery(view="favorites", cursor=first.next_cursor)) + + +def test_connections_paginate_across_tenants_without_merging_provider_names(harness: ConsumerHarness) -> None: + base = harness.oauth.base + rows = [replace(new_connection(), tenant_id=str(uuid4()), account_id=harness.identity.account_id) for _ in range(3)] + with base.sessions.begin() as session: + session.add_all(rows) + first = harness.service.list_connections(harness.identity, provider=None, cursor=None, limit=2) + second = harness.service.list_connections(harness.identity, provider=None, cursor=first.next_cursor, limit=2) + assert len(first.data) == 2 + assert first.has_more + assert len(second.data) == 1 + assert not second.has_more + assert {row.connection.id for row in (*first.data, *second.data)} == {row.id for row in rows} diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index 066984fbdbf163..597550bdc75497 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -14,6 +14,7 @@ export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } f export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { delete2Errors as 'DELETE /installed-apps/{installed_app_id}/saved-messages/{message_id}' } from './installed-apps/errors.gen.ts' export { delete2Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/app-bindings/{caller_kind}/{app_id}' } from './knowledge-fs/errors.gen.ts' +export { delete2Errors as 'DELETE /me/connections/{connection_id}' } from './me/errors.gen.ts' export { delete2Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { delete3Errors as 'DELETE /agent/{agent_id}/config/files/{name}' } from './agent/errors.gen.ts' export { delete3Errors as 'DELETE /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' @@ -128,6 +129,7 @@ export { get2Errors as 'GET /agent/{agent_id}/api-access' } from './agent/errors export { get2Errors as 'GET /billing/subscription' } from './billing/errors.gen.ts' export { get2Errors as 'GET /explore/apps/{app_id}' } from './explore/errors.gen.ts' export { get2Errors as 'GET /installed-apps/{installed_app_id}/messages/{message_id}/more-like-this' } from './installed-apps/errors.gen.ts' +export { get2Errors as 'GET /me/connections/{connection_id}/credential-schema' } from './me/errors.gen.ts' export { get2Errors as 'GET /notion/pre-import/pages' } from './notion/errors.gen.ts' export { get2Errors as 'GET /trial-apps/{app_id}/agent/config/files/{name}/download' } from './trial-apps/errors.gen.ts' export { get30Errors as 'GET /agent/{agent_id}/trace-capabilities' } from './agent/errors.gen.ts' @@ -168,6 +170,7 @@ export { get39Errors as 'GET /knowledge-fs/spaces/{control_space_id}/settings/mi export { get39Errors as 'GET /workspaces/current/plugin/tasks' } from './workspaces/errors.gen.ts' export { get3Errors as 'GET /installed-apps/{installed_app_id}/messages/{message_id}/suggested-questions' } from './installed-apps/errors.gen.ts' export { get3Errors as 'GET /knowledge-fs/research-tasks/{task_id}/events' } from './knowledge-fs/errors.gen.ts' +export { get3Errors as 'GET /me/connections/{connection_id}/revision' } from './me/errors.gen.ts' export { get3Errors as 'GET /trial-apps/{app_id}/agent/config/files/{name}/preview' } from './trial-apps/errors.gen.ts' export { get40Errors as 'GET /apps/{app_id}/statistics/daily-messages' } from './apps/errors.gen.ts' export { get40Errors as 'GET /knowledge-fs/spaces/{control_space_id}/settings' } from './knowledge-fs/errors.gen.ts' @@ -194,6 +197,7 @@ export { get4Errors as 'GET /agent/{agent_id}/build-draft' } from './agent/error export { get4Errors as 'GET /apps/{app_id}/advanced-chat/workflow-runs/count' } from './apps/errors.gen.ts' export { get4Errors as 'GET /installed-apps/{installed_app_id}/messages' } from './installed-apps/errors.gen.ts' export { get4Errors as 'GET /knowledge-fs/source-provider-preview/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' +export { get4Errors as 'GET /me/connections/{connection_id}' } from './me/errors.gen.ts' export { get4Errors as 'GET /rag/pipelines/imports/{pipeline_id}/check-dependencies' } from './rag/errors.gen.ts' export { get4Errors as 'GET /trial-apps/{app_id}/agent/config/files' } from './trial-apps/errors.gen.ts' export { get50Errors as 'GET /apps/{app_id}/workflow-app-logs' } from './apps/errors.gen.ts' @@ -319,6 +323,7 @@ export { patchErrors as 'PATCH /data-source/integrates/{binding_id}/{action}' } export { patchErrors as 'PATCH /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { patchErrors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/pin' } from './installed-apps/errors.gen.ts' export { patchErrors as 'PATCH /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' +export { patchErrors as 'PATCH /me/connections/{connection_id}' } from './me/errors.gen.ts' export { post10Errors as 'POST /account/interface-language' } from './account/errors.gen.ts' export { post10Errors as 'POST /agent/{agent_id}/config/skills/upload' } from './agent/errors.gen.ts' export { post10Errors as 'POST /apps/{app_id}/agent/config/skills/upload' } from './apps/errors.gen.ts' @@ -540,6 +545,7 @@ export { postErrors as 'POST /form/human_input/{form_token}' } from './form/erro export { postErrors as 'POST /installed-apps/{installed_app_id}/audio-to-text' } from './installed-apps/errors.gen.ts' export { postErrors as 'POST /knowledge-fs/query-stream' } from './knowledge-fs/errors.gen.ts' export { postErrors as 'POST /login' } from './login/errors.gen.ts' +export { postErrors as 'POST /me/connections/{connection_id}/reauthorize' } from './me/errors.gen.ts' export { postErrors as 'POST /remote-files/upload' } from './remote-files/errors.gen.ts' export { postErrors as 'POST /reset-password' } from './reset-password/errors.gen.ts' export { postErrors as 'POST /rule-code-generate' } from './rule-code-generate/errors.gen.ts' diff --git a/packages/contracts/generated/api/console/me/errors.gen.ts b/packages/contracts/generated/api/console/me/errors.gen.ts new file mode 100644 index 00000000000000..54baaaf3e64511 --- /dev/null +++ b/packages/contracts/generated/api/console/me/errors.gen.ts @@ -0,0 +1,132 @@ +// This file is auto-generated by scripts/add-orpc-error-maps.ts +import * as z from 'zod' + +const zErrorResponse = /* @__PURE__ */ (() => + z.object({ + code: z.string(), + details: z.record(z.string(), z.unknown()).optional(), + message: z.string(), + params: z.string().optional(), + status: z.int(), + }))() + +const zConnectionNotFoundError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_not_found'), + status: z.literal(404), + }), + ))() + +const zConnectionConflictError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_conflict'), + status: z.literal(409), + }), + ))() + +const zConnectionConfigurationInvalidError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_configuration_invalid'), + status: z.literal(400), + }), + ))() + +const zConnectionCredentialsInvalidError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_credentials_invalid'), + status: z.literal(400), + }), + ))() + +const zConnectionCredentialsUnavailableError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_credentials_unavailable'), + status: z.literal(503), + }), + ))() + +const zCredentialCheckUnavailableError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('credential_check_unavailable'), + status: z.literal(503), + }), + ))() + +const zConnectionOauthFailedError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_oauth_failed'), + status: z.literal(400), + }), + ))() + +const zConnectionReauthorizationRequiredError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_reauthorization_required'), + status: z.literal(409), + }), + ))() + +const zConnectionRevisionChangeDetails = /* @__PURE__ */ (() => + z.object({ + connection_revision: z.uuid().nullable(), + }))() + +const zConnectionRevisionChangedError = /* @__PURE__ */ (() => + zErrorResponse.and( + z.object({ + code: z.literal('connection_revision_changed'), + details: zConnectionRevisionChangeDetails.optional(), + status: z.literal(409), + }), + ))() + +export const get2Errors = { + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, +} + +export const postErrors = { + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_credentials_unavailable: { status: 503, data: zConnectionCredentialsUnavailableError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_oauth_failed: { status: 400, data: zConnectionOauthFailedError }, + connection_reauthorization_required: { + status: 409, + data: zConnectionReauthorizationRequiredError, + }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, +} + +export const get3Errors = { + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, +} + +export const delete2Errors = { + connection_conflict: { status: 409, data: zConnectionConflictError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, +} + +export const get4Errors = { connection_not_found: { status: 404, data: zConnectionNotFoundError } } + +export const patchErrors = { + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_conflict: { status: 409, data: zConnectionConflictError }, + connection_credentials_invalid: { status: 400, data: zConnectionCredentialsInvalidError }, + connection_credentials_unavailable: { status: 503, data: zConnectionCredentialsUnavailableError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, +} diff --git a/packages/contracts/generated/api/console/me/orpc.gen.ts b/packages/contracts/generated/api/console/me/orpc.gen.ts new file mode 100644 index 00000000000000..965780a00c61c6 --- /dev/null +++ b/packages/contracts/generated/api/console/me/orpc.gen.ts @@ -0,0 +1,260 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' +import { + zConnectionConfigurationInvalidError, + zConnectionConflictError, + zConnectionCredentialsInvalidError, + zConnectionCredentialsUnavailableError, + zConnectionNotFoundError, + zConnectionOauthFailedError, + zConnectionReauthorizationRequiredError, + zConnectionRevisionChangedError, + zCredentialCheckUnavailableError, +} from './zod.gen.ts' +import { + zDeleteMeAppsByAppIdFavoritePath, + zDeleteMeAppsByAppIdFavoriteResponse, + zDeleteMeConnectionsByConnectionIdPath, + zDeleteMeConnectionsByConnectionIdResponse, + zGetMeAppsQuery, + zGetMeAppsResponse, + zGetMeConnectionsByConnectionIdCredentialSchemaPath, + zGetMeConnectionsByConnectionIdCredentialSchemaQuery, + zGetMeConnectionsByConnectionIdCredentialSchemaResponse, + zGetMeConnectionsByConnectionIdPath, + zGetMeConnectionsByConnectionIdResponse, + zGetMeConnectionsByConnectionIdRevisionPath, + zGetMeConnectionsByConnectionIdRevisionResponse, + zGetMeConnectionsQuery, + zGetMeConnectionsResponse, + zPatchMeConnectionsByConnectionIdBody, + zPatchMeConnectionsByConnectionIdPath, + zPatchMeConnectionsByConnectionIdResponse, + zPostMeConnectionsByConnectionIdReauthorizeBody, + zPostMeConnectionsByConnectionIdReauthorizePath, + zPostMeConnectionsByConnectionIdReauthorizeResponse, + zPutMeAppsByAppIdFavoritePath, + zPutMeAppsByAppIdFavoriteResponse, +} from './zod.gen.ts' + +export const delete_ = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteMeAppsByAppIdFavorite', + path: '/me/apps/{app_id}/favorite', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteMeAppsByAppIdFavoritePath })) + .output(zDeleteMeAppsByAppIdFavoriteResponse) + +export const put = oc + .route({ + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putMeAppsByAppIdFavorite', + path: '/me/apps/{app_id}/favorite', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zPutMeAppsByAppIdFavoritePath })) + .output(zPutMeAppsByAppIdFavoriteResponse) + +export const favorite = { + delete: delete_, + put, +} + +export const byAppId = { + favorite, +} + +export const get = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getMeApps', + path: '/me/apps', + tags: ['console'], + }) + .input(z.object({ query: zGetMeAppsQuery.optional() }).optional()) + .output(zGetMeAppsResponse) + +export const apps = { + get, + byAppId, +} + +export const get2 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getMeConnectionsByConnectionIdCredentialSchema', + path: '/me/connections/{connection_id}/credential-schema', + tags: ['console'], + }) + .input( + z.object({ + params: zGetMeConnectionsByConnectionIdCredentialSchemaPath, + query: zGetMeConnectionsByConnectionIdCredentialSchemaQuery, + }), + ) + .output(zGetMeConnectionsByConnectionIdCredentialSchemaResponse) + .errors({ + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, + }) + +export const credentialSchema = { + get: get2, +} + +export const post = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postMeConnectionsByConnectionIdReauthorize', + path: '/me/connections/{connection_id}/reauthorize', + tags: ['console'], + }) + .input( + z.object({ + body: zPostMeConnectionsByConnectionIdReauthorizeBody, + params: zPostMeConnectionsByConnectionIdReauthorizePath, + }), + ) + .output(zPostMeConnectionsByConnectionIdReauthorizeResponse) + .errors({ + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_credentials_unavailable: { + status: 503, + data: zConnectionCredentialsUnavailableError, + }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_oauth_failed: { status: 400, data: zConnectionOauthFailedError }, + connection_reauthorization_required: { + status: 409, + data: zConnectionReauthorizationRequiredError, + }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, + }) + +export const reauthorize = { + post, +} + +export const get3 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getMeConnectionsByConnectionIdRevision', + path: '/me/connections/{connection_id}/revision', + tags: ['console'], + }) + .input(z.object({ params: zGetMeConnectionsByConnectionIdRevisionPath })) + .output(zGetMeConnectionsByConnectionIdRevisionResponse) + .errors({ + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, + }) + +export const revision = { + get: get3, +} + +export const delete2 = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteMeConnectionsByConnectionId', + path: '/me/connections/{connection_id}', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteMeConnectionsByConnectionIdPath })) + .output(zDeleteMeConnectionsByConnectionIdResponse) + .errors({ + connection_conflict: { status: 409, data: zConnectionConflictError }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + }) + +export const get4 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getMeConnectionsByConnectionId', + path: '/me/connections/{connection_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetMeConnectionsByConnectionIdPath })) + .output(zGetMeConnectionsByConnectionIdResponse) + .errors({ connection_not_found: { status: 404, data: zConnectionNotFoundError } }) + +export const patch = oc + .route({ + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchMeConnectionsByConnectionId', + path: '/me/connections/{connection_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPatchMeConnectionsByConnectionIdBody, + params: zPatchMeConnectionsByConnectionIdPath, + }), + ) + .output(zPatchMeConnectionsByConnectionIdResponse) + .errors({ + connection_configuration_invalid: { status: 400, data: zConnectionConfigurationInvalidError }, + connection_conflict: { status: 409, data: zConnectionConflictError }, + connection_credentials_invalid: { status: 400, data: zConnectionCredentialsInvalidError }, + connection_credentials_unavailable: { + status: 503, + data: zConnectionCredentialsUnavailableError, + }, + connection_not_found: { status: 404, data: zConnectionNotFoundError }, + connection_revision_changed: { status: 409, data: zConnectionRevisionChangedError }, + credential_check_unavailable: { status: 503, data: zCredentialCheckUnavailableError }, + }) + +export const byConnectionId = { + delete: delete2, + get: get4, + patch, + credentialSchema, + reauthorize, + revision, +} + +export const get5 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getMeConnections', + path: '/me/connections', + tags: ['console'], + }) + .input(z.object({ query: zGetMeConnectionsQuery.optional() }).optional()) + .output(zGetMeConnectionsResponse) + +export const connections = { + get: get5, + byConnectionId, +} + +export const me = { + apps, + connections, +} + +export const contract = { + me, +} diff --git a/packages/contracts/generated/api/console/me/types.gen.ts b/packages/contracts/generated/api/console/me/types.gen.ts new file mode 100644 index 00000000000000..9d1784d24c54bb --- /dev/null +++ b/packages/contracts/generated/api/console/me/types.gen.ts @@ -0,0 +1,435 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/console/api` | (string & {}) +} + +export type ConsumerAppsResponse = { + data: Array + has_more: boolean + next_cursor: string | null +} + +export type ErrorResponse = { + code: string + details?: { + [key: string]: unknown + } + message: string + params?: string + status: number +} + +export type ConsumerConnectionsResponse = { + data: Array + has_more: boolean + next_cursor: string | null +} + +export type ConnectionNotFoundError = ErrorResponse & { + code: 'connection_not_found' + status: 404 +} + +export type ConnectionConflictError = ErrorResponse & { + code: 'connection_conflict' + status: 409 +} + +export type ConsumerConnectionDetailResponse = { + apps: Array + connection: ConsumerConnectionResponse +} + +export type ConsumerConnectionUpdatePayload = { + connection_revision?: string + credentials?: { + [key: string]: JsonValue + } + name?: string +} + +export type ConsumerConnectionResponse = { + account_label: string | null + created_at: number + credential_status: ConnectionCredentialStatus + credential_type: ConnectionCredentialType + expires_at: number | null + id: string + last_active_at: number + name: string + provider: string + updated_at: number +} + +export type ConnectionConfigurationInvalidError = ErrorResponse & { + code: 'connection_configuration_invalid' + status: 400 +} + +export type ConnectionCredentialsInvalidError = ErrorResponse & { + code: 'connection_credentials_invalid' + status: 400 +} + +export type ConnectionRevisionChangedError = ErrorResponse & { + code: 'connection_revision_changed' + details?: ConnectionRevisionChangeDetails + status: 409 +} + +export type ConnectionCredentialsUnavailableError = ErrorResponse & { + code: 'connection_credentials_unavailable' + status: 503 +} + +export type CredentialCheckUnavailableError = ErrorResponse & { + code: 'credential_check_unavailable' + status: 503 +} + +export type ConsumerCredentialSchemaResponse = Array + +export type ConsumerReauthorizePayload = { + connection_revision: string +} + +export type ConsumerOAuthResponse = { + authorization_url: string +} + +export type ConnectionOauthFailedError = ErrorResponse & { + code: 'connection_oauth_failed' + status: 400 +} + +export type ConnectionReauthorizationRequiredError = ErrorResponse & { + code: 'connection_reauthorization_required' + status: 409 +} + +export type ConsumerConnectionRevisionResponse = { + connection_revision: string +} + +export type ConsumerAppResponse = { + app_code: string + description: string + icon: string | null + icon_background: string | null + icon_type: string | null + id: string + is_favorite: boolean + last_used_at: number | null + mode: string + name: string +} + +export type JsonValue = unknown + +export type ConnectionCredentialStatus = 'connected' | 'invalid' | 'reconnect_required' + +export type ConnectionCredentialType = 'api-key' | 'oauth2' + +export type ConnectionRevisionChangeDetails = { + connection_revision: string | null +} + +export type ProviderConfig = { + default?: number | string | number | boolean | null + help?: I18nObject | null + label?: I18nObject | null + multiple?: boolean + name: string + options?: Array