mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
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:
parent
7b6ceaebea
commit
35c08f7c3d
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -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(
|
||||
|
||||
@ -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)),
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user