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
This commit is contained in:
GareArc 2026-05-06 14:11:51 -07:00
parent 7b6ceaebea
commit 35c08f7c3d
No known key found for this signature in database
8 changed files with 136 additions and 242 deletions

View File

@ -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",

View File

@ -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

View File

@ -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(

View File

@ -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)),
)

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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):