From 35c08f7c3dba5e6b15e14d469f2b2805e7020a6d Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 6 May 2026 14:11:51 -0700 Subject: [PATCH] feat(enterprise): wire /apps/permitted via EE wrapper (app_ids only) services/enterprise/app_permitted_service.list_permitted_apps calls EnterpriseService.WebAppAuth.list_externally_accessible_apps and decodes the camelCase wrapper response into a PermittedAppsPage carrying just app_ids. The controller hydrates name/mode/tenant/etc. from local App/Tenant rows. The EE response shape is {data: [appId], total, hasMore} per ee-2. EE owns access control only; dify/api owns app data, so the older inner-API metadata fanout (/inner/api/enterprise/apps/batch-metadata) is removed. - delete controllers/inner_api/app/metadata.py + its test - service: ServiceUnavailable on EnterpriseAPIError; 5s timeout via wrapper - controller: drop fail-fast subject check + unused g/InternalServerError imports --- api/controllers/inner_api/__init__.py | 2 - api/controllers/inner_api/app/metadata.py | 66 --------- api/controllers/openapi/apps_permitted.py | 22 +-- .../enterprise/app_permitted_service.py | 42 +++--- api/services/enterprise/enterprise_service.py | 26 ++++ .../inner_api/test_app_metadata.py | 138 ------------------ .../enterprise/test_app_permitted_service.py | 57 ++++++++ .../enterprise/test_enterprise_service.py | 25 ++++ 8 files changed, 136 insertions(+), 242 deletions(-) delete mode 100644 api/controllers/inner_api/app/metadata.py delete mode 100644 api/tests/unit_tests/controllers/inner_api/test_app_metadata.py create mode 100644 api/tests/unit_tests/services/enterprise/test_app_permitted_service.py diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index 10ba6fe8b7..b38994f055 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -17,7 +17,6 @@ inner_api_ns = Namespace("inner_api", description="Internal API operations", pat from . import mail as _mail from .app import dsl as _app_dsl -from .app import metadata as _app_metadata from .plugin import plugin as _plugin from .workspace import workspace as _workspace @@ -25,7 +24,6 @@ api.add_namespace(inner_api_ns) __all__ = [ "_app_dsl", - "_app_metadata", "_mail", "_plugin", "_workspace", diff --git a/api/controllers/inner_api/app/metadata.py b/api/controllers/inner_api/app/metadata.py deleted file mode 100644 index 9d5ae063a9..0000000000 --- a/api/controllers/inner_api/app/metadata.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Inner endpoint: batch-fetch app metadata for the EE permitted-apps flow. - -Called by ``dify-enterprise`` server to hydrate ``web_app_settings.app_id`` rows -with their ``apps`` table fields. Filters out non-``normal`` apps server-side. -""" - -import sqlalchemy as sa -from flask_restx import Resource -from pydantic import BaseModel, ConfigDict, Field, ValidationError - -from controllers.common.schema import register_schema_model -from controllers.console.wraps import setup_required -from controllers.inner_api import inner_api_ns -from controllers.inner_api.wraps import enterprise_inner_api_only -from extensions.ext_database import db -from models import App -from models.enums import AppStatus - - -class InnerAppBatchMetadataPayload(BaseModel): - model_config = ConfigDict(extra="forbid") - - ids: list[str] = Field(min_length=1, max_length=500, description="App UUIDs to fetch metadata for (1-500 entries)") - - -register_schema_model(inner_api_ns, InnerAppBatchMetadataPayload) - - -@inner_api_ns.route("/enterprise/apps/batch-metadata") -class InnerAppBatchMetadataApi(Resource): - @setup_required - @enterprise_inner_api_only - @inner_api_ns.doc("enterprise_app_batch_metadata") - @inner_api_ns.expect(inner_api_ns.models[InnerAppBatchMetadataPayload.__name__]) - @inner_api_ns.doc( - responses={ - 200: "Batch metadata returned", - 401: "Inner API key invalid", - 422: "Payload validation failed", - } - ) - def post(self): - """Batch-fetch app metadata for the EE permitted-apps flow.""" - try: - payload = InnerAppBatchMetadataPayload.model_validate(inner_api_ns.payload or {}) - except ValidationError as exc: - return {"message": exc.json()}, 422 - - rows = ( - db.session.execute(sa.select(App).where(App.id.in_(payload.ids), App.status == AppStatus.NORMAL)) - .scalars() - .all() - ) - - return { - "data": [ - { - "id": str(app.id), - "tenant_id": str(app.tenant_id), - "mode": app.mode, - "name": app.name, - "updated_at": app.updated_at.isoformat(), - } - for app in rows - ] - }, 200 diff --git a/api/controllers/openapi/apps_permitted.py b/api/controllers/openapi/apps_permitted.py index 1aae847973..2e624b6c98 100644 --- a/api/controllers/openapi/apps_permitted.py +++ b/api/controllers/openapi/apps_permitted.py @@ -3,10 +3,10 @@ from __future__ import annotations import sqlalchemy as sa -from flask import g, request +from flask import request from flask_restx import Resource from pydantic import BaseModel, ConfigDict, Field, ValidationError -from werkzeug.exceptions import InternalServerError, UnprocessableEntity +from werkzeug.exceptions import UnprocessableEntity from controllers.openapi import openapi_ns from controllers.openapi._models import ( @@ -52,29 +52,22 @@ class AppPermittedListApi(Resource): except ValidationError as exc: raise UnprocessableEntity(exc.json()) - ctx = g.auth_ctx - # Fail-fast: empty subject would silently corrupt the EE allow-list query. - if not ctx.subject_email or not ctx.subject_issuer: - raise InternalServerError("malformed external_sso bearer: missing subject identity") - page_result = list_permitted_apps( - subject_email=ctx.subject_email, - subject_issuer=ctx.subject_issuer, page=query.page, limit=query.limit, mode=query.mode.value if query.mode else None, name=query.name, ) - if not page_result.data: + if not page_result.app_ids: env = PaginationEnvelope[AppListRow].build( page=query.page, limit=query.limit, total=page_result.total, items=[] ) return env.model_dump(mode="json"), 200 - app_ids = [r.app_id for r in page_result.data] apps_by_id = { - str(a.id): a for a in db.session.execute(sa.select(App).where(App.id.in_(app_ids))).scalars().all() + str(a.id): a + for a in db.session.execute(sa.select(App).where(App.id.in_(page_result.app_ids))).scalars().all() } tenant_ids = list({a.tenant_id for a in apps_by_id.values()}) tenants_by_id = { @@ -82,10 +75,9 @@ class AppPermittedListApi(Resource): } items: list[AppListRow] = [] - for r in page_result.data: - app = apps_by_id.get(r.app_id) + for app_id in page_result.app_ids: + app = apps_by_id.get(app_id) if not app or app.status != "normal": - # Skip allow-list entries where the app is missing/archived. continue tenant = tenants_by_id.get(str(app.tenant_id)) items.append( diff --git a/api/services/enterprise/app_permitted_service.py b/api/services/enterprise/app_permitted_service.py index d3bc1ad371..77d6346995 100644 --- a/api/services/enterprise/app_permitted_service.py +++ b/api/services/enterprise/app_permitted_service.py @@ -1,44 +1,44 @@ -"""Enterprise inner-API client for the /apps/permitted route. - -Wraps `POST /inner/api/webapp/permitted-apps` (defined in ee-2). Until -ee-2 ships the endpoint, every call surfaces 503 from the dify-api side. -This isolates the wire-up so the route + scope + query model can ship -ahead of the cross-repo dependency. -""" - from __future__ import annotations +import logging from dataclasses import dataclass from werkzeug.exceptions import ServiceUnavailable +from services.enterprise.enterprise_service import EnterpriseService +from services.errors.enterprise import EnterpriseAPIError -@dataclass(frozen=True, slots=True) -class PermittedAppRow: - app_id: str - tenant_id: str +logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) class PermittedAppsPage: - data: list[PermittedAppRow] + app_ids: list[str] total: int has_more: bool def list_permitted_apps( *, - subject_email: str, - subject_issuer: str, page: int, limit: int, mode: str | None = None, name: str | None = None, ) -> PermittedAppsPage: - """Cross-tenant allow-list query for `dfoe_` discovery. + try: + body = EnterpriseService.WebAppAuth.list_externally_accessible_apps( + page=page, limit=limit, mode=mode, name=name + ) + except EnterpriseAPIError as exc: + logger.warning( + "permitted_apps EE call failed: status=%s message=%s", + getattr(exc, "status_code", None), + str(exc), + ) + raise ServiceUnavailable("permitted_apps_unavailable") from exc - TODO(ee-2): wire to `POST /inner/api/webapp/permitted-apps`. Until then - every call returns 503 to keep CLI-side work unblocked behind a stable - server contract. - """ - raise ServiceUnavailable("permitted_apps_unavailable") + return PermittedAppsPage( + app_ids=[row["appId"] for row in body.get("data", [])], + total=int(body.get("total", 0)), + has_more=bool(body.get("hasMore", False)), + ) diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 6d61a3c3e5..e2a2bbb9b3 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -243,6 +243,32 @@ class EnterpriseService: params = {"appId": app_id} EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params) + @classmethod + def list_externally_accessible_apps( + cls, + *, + page: int, + limit: int, + mode: str | None = None, + name: str | None = None, + ) -> dict: + """Call EE InnerListExternallyAccessibleApps; returns raw camelCase response. + + Response shape: ``{"data": [{"appId", "tenantId", "mode", "name", "updatedAt"}], + "total": int, "hasMore": bool}``. + """ + body: dict[str, str | int] = {"page": page, "limit": limit} + if mode is not None: + body["mode"] = mode + if name is not None: + body["name"] = name + return EnterpriseRequest.send_request( + "POST", + "/webapp/externally-accessible-apps", + json=body, + timeout=5.0, + ) + @classmethod def get_cached_license_status(cls) -> LicenseStatus | None: """Get enterprise license status with Redis caching to reduce HTTP calls. diff --git a/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py b/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py deleted file mode 100644 index 8b03d9ca7f..0000000000 --- a/api/tests/unit_tests/controllers/inner_api/test_app_metadata.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Inner endpoint to batch-fetch app metadata for the EE permitted-apps flow.""" - -import builtins -import inspect -import uuid -from datetime import UTC, datetime -from unittest.mock import MagicMock, patch - -import pytest -from flask import Flask -from flask.views import MethodView -from pydantic import ValidationError - -from controllers.inner_api import bp as inner_api_bp -from controllers.inner_api.app.metadata import InnerAppBatchMetadataApi, InnerAppBatchMetadataPayload - -if not hasattr(builtins, "MethodView"): - builtins.MethodView = MethodView # type: ignore[attr-defined] - - -@pytest.fixture -def inner_app() -> Flask: - app = Flask(__name__) - app.config["TESTING"] = True - app.register_blueprint(inner_api_bp) - return app - - -def test_route_registered(inner_app: Flask): - rules = {r.rule for r in inner_app.url_map.iter_rules()} - assert "/inner/api/enterprise/apps/batch-metadata" in rules - - -def test_dispatches_to_class(inner_app: Flask): - rule = next(r for r in inner_app.url_map.iter_rules() if r.rule == "/inner/api/enterprise/apps/batch-metadata") - assert inner_app.view_functions[rule.endpoint].view_class is InnerAppBatchMetadataApi - - -def test_post_method_only(inner_app: Flask): - rule = next(r for r in inner_app.url_map.iter_rules() if r.rule == "/inner/api/enterprise/apps/batch-metadata") - assert "POST" in rule.methods - assert "GET" not in rule.methods - - -def test_payload_rejects_empty_ids(): - with pytest.raises(ValidationError): - InnerAppBatchMetadataPayload.model_validate({"ids": []}) - - -def test_payload_rejects_too_many_ids(): - with pytest.raises(ValidationError): - InnerAppBatchMetadataPayload.model_validate({"ids": ["x"] * 501}) - - -def _make_app_row(*, id=None, tenant_id=None, mode="chat", name="Test", updated_at=None, status="normal"): - """Build a stand-in App row for handler tests.""" - row = MagicMock() - row.id = id or uuid.uuid4() - row.tenant_id = tenant_id or uuid.uuid4() - row.mode = mode - row.name = name - row.status = status - row.updated_at = updated_at or datetime(2026, 5, 6, 12, 0, 0, tzinfo=UTC) - return row - - -def test_post_returns_metadata_for_normal_apps(inner_app: Flask): - app_row = _make_app_row(name="App A", mode="workflow") - - api_instance = InnerAppBatchMetadataApi() - handler = inspect.unwrap(api_instance.post) - - with inner_app.test_request_context( - path="/inner/api/enterprise/apps/batch-metadata", - method="POST", - json={"ids": [str(app_row.id)]}, - ): - with ( - patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns, - patch("controllers.inner_api.app.metadata.db") as mock_db, - ): - mock_ns.payload = {"ids": [str(app_row.id)]} - mock_scalars = MagicMock() - mock_scalars.all.return_value = [app_row] - mock_db.session.execute.return_value.scalars.return_value = mock_scalars - - body, status = handler(api_instance) - - assert status == 200 - assert body == { - "data": [ - { - "id": str(app_row.id), - "tenant_id": str(app_row.tenant_id), - "mode": "workflow", - "name": "App A", - "updated_at": "2026-05-06T12:00:00+00:00", - } - ] - } - - -def test_post_returns_empty_data_when_no_apps(inner_app: Flask): - api_instance = InnerAppBatchMetadataApi() - handler = inspect.unwrap(api_instance.post) - - with inner_app.test_request_context( - path="/inner/api/enterprise/apps/batch-metadata", - method="POST", - json={"ids": [str(uuid.uuid4())]}, - ): - with ( - patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns, - patch("controllers.inner_api.app.metadata.db") as mock_db, - ): - mock_ns.payload = {"ids": [str(uuid.uuid4())]} - mock_scalars = MagicMock() - mock_scalars.all.return_value = [] - mock_db.session.execute.return_value.scalars.return_value = mock_scalars - - body, status = handler(api_instance) - - assert status == 200 - assert body == {"data": []} - - -def test_post_returns_422_on_invalid_payload(inner_app: Flask): - api_instance = InnerAppBatchMetadataApi() - handler = inspect.unwrap(api_instance.post) - - with inner_app.test_request_context(path="/inner/api/enterprise/apps/batch-metadata", method="POST"): - with patch("controllers.inner_api.app.metadata.inner_api_ns") as mock_ns: - mock_ns.payload = {"ids": []} # min_length=1 violation - - body, status = handler(api_instance) - - assert status == 422 - assert "message" in body diff --git a/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py b/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py new file mode 100644 index 0000000000..339f783ca8 --- /dev/null +++ b/api/tests/unit_tests/services/enterprise/test_app_permitted_service.py @@ -0,0 +1,57 @@ +from unittest.mock import patch + +import pytest + +from services.enterprise.app_permitted_service import PermittedAppsPage, list_permitted_apps +from services.errors.enterprise import EnterpriseAPIError + +WRAPPER = "services.enterprise.app_permitted_service.EnterpriseService.WebAppAuth.list_externally_accessible_apps" + + +def test_list_permitted_apps_decodes_camelcase_response(): + fake_body = { + "data": [{"appId": "a"}, {"appId": "b"}], + "total": 2, + "hasMore": False, + } + with patch(WRAPPER, return_value=fake_body) as m: + page = list_permitted_apps(page=1, limit=10) + + assert isinstance(page, PermittedAppsPage) + assert page.total == 2 + assert page.has_more is False + assert page.app_ids == ["a", "b"] + m.assert_called_once_with(page=1, limit=10, mode=None, name=None) + + +def test_list_permitted_apps_passes_filters_to_wrapper(): + fake_body = {"data": [], "total": 0, "hasMore": False} + with patch(WRAPPER, return_value=fake_body) as m: + list_permitted_apps(page=2, limit=5, mode="workflow", name="alpha") + + m.assert_called_once_with(page=2, limit=5, mode="workflow", name="alpha") + + +def test_list_permitted_apps_503_on_ee_error(): + with patch(WRAPPER, side_effect=EnterpriseAPIError("boom", status_code=500)): + from werkzeug.exceptions import ServiceUnavailable + + with pytest.raises(ServiceUnavailable): + list_permitted_apps(page=1, limit=10) + + +def test_list_permitted_apps_503_on_status_error(): + with patch(WRAPPER, side_effect=EnterpriseAPIError("bad key", status_code=401)): + from werkzeug.exceptions import ServiceUnavailable + + with pytest.raises(ServiceUnavailable): + list_permitted_apps(page=1, limit=10) + + +def test_list_permitted_apps_handles_empty_response(): + fake_body = {"data": [], "total": 0, "hasMore": False} + with patch(WRAPPER, return_value=fake_body): + page = list_permitted_apps(page=1, limit=10) + assert page.app_ids == [] + assert page.total == 0 + assert page.has_more is False diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py index 6ad6a490b0..599a9a7b95 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -188,6 +188,31 @@ class TestWebAppAuth: req.send_request.assert_called_once_with("DELETE", "/webapp/clean", params={"appId": "a1"}) + def test_list_externally_accessible_apps_minimal_call(self): + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"data": [], "total": 0, "hasMore": False} + result = EnterpriseService.WebAppAuth.list_externally_accessible_apps(page=1, limit=10) + + assert result == {"data": [], "total": 0, "hasMore": False} + req.send_request.assert_called_once_with( + "POST", + "/webapp/externally-accessible-apps", + json={"page": 1, "limit": 10}, + timeout=5.0, + ) + + def test_list_externally_accessible_apps_with_filters(self): + with patch(f"{MODULE}.EnterpriseRequest") as req: + req.send_request.return_value = {"data": [], "total": 0, "hasMore": False} + EnterpriseService.WebAppAuth.list_externally_accessible_apps(page=2, limit=5, mode="workflow", name="alpha") + + req.send_request.assert_called_once_with( + "POST", + "/webapp/externally-accessible-apps", + json={"page": 2, "limit": 5, "mode": "workflow", "name": "alpha"}, + timeout=5.0, + ) + class TestJoinDefaultWorkspace: def test_join_default_workspace_success(self):