diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 63b30846e1..baa481785d 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -20,6 +20,7 @@ from . import ( account, app_info, apps, + apps_permitted, chat_messages, completion_messages, index, @@ -33,6 +34,7 @@ __all__ = [ "account", "app_info", "apps", + "apps_permitted", "chat_messages", "completion_messages", "index", diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index ee361531e9..8ff218b536 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -45,6 +45,8 @@ class AppListRow(BaseModel): tags: list[dict[str, str]] = [] updated_at: str | None = None created_by_name: str | None = None + workspace_id: str | None = None + workspace_name: str | None = None class AppInfoResponse(BaseModel): diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 6396d162fe..6e7167db6b 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -41,7 +41,7 @@ from libs.oauth_bearer import ( require_workspace_member, validate_bearer, ) -from models import App +from models import App, Tenant from models.model import AppMode, Tag, TagBinding # Shared decorator stack for `apps:read`-scoped endpoints. List order is @@ -185,6 +185,8 @@ class AppListApi(Resource): workspace_id = query.workspace_id require_workspace_member(ctx, workspace_id) + tenant_name = db.session.execute(sa.select(Tenant.name).where(Tenant.id == workspace_id)).scalar_one_or_none() + page = query.page limit = query.limit mode = query.mode.value if query.mode else None @@ -228,6 +230,8 @@ class AppListApi(Resource): tags=[{"name": t.name} for t in r.tags], updated_at=r.updated_at.isoformat() if r.updated_at else None, created_by_name=getattr(r, "author_name", None), + workspace_id=str(workspace_id), + workspace_name=tenant_name, ) for r in rows ] diff --git a/api/controllers/openapi/apps_permitted.py b/api/controllers/openapi/apps_permitted.py new file mode 100644 index 0000000000..74fa1147b7 --- /dev/null +++ b/api/controllers/openapi/apps_permitted.py @@ -0,0 +1,109 @@ +"""GET /openapi/v1/apps/permitted — external-subject app discovery (EE only).""" + +from __future__ import annotations + +import sqlalchemy as sa +from flask import g, request +from flask_restx import Resource +from pydantic import BaseModel, ConfigDict, Field, ValidationError +from werkzeug.exceptions import UnprocessableEntity + +from controllers.openapi import openapi_ns +from controllers.openapi._models import ( + MAX_PAGE_LIMIT, + AppListRow, + PaginationEnvelope, +) +from extensions.ext_database import db +from libs.device_flow_security import enterprise_only +from libs.oauth_bearer import ( + ACCEPT_USER_EXT_SSO, + Scope, + require_scope, + validate_bearer, +) +from models import App, Tenant +from models.model import AppMode +from services.enterprise.app_permitted_service import list_permitted_apps + + +class AppPermittedListQuery(BaseModel): + """Query-param validator for `GET /openapi/v1/apps/permitted`. + + Strict — `extra='forbid'` rejects `workspace_id`, `tag`, and any other + param not explicitly allowed for this surface. Returns 422 on violation. + """ + + model_config = ConfigDict(extra="forbid") + + page: int = Field(1, ge=1) + limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT) + mode: AppMode | None = None + name: str | None = Field(None, max_length=200) + + +@openapi_ns.route("/apps/permitted") +class AppListPermittedApi(Resource): + method_decorators = [ + require_scope(Scope.APPS_READ_PERMITTED), + validate_bearer(accept=ACCEPT_USER_EXT_SSO), + enterprise_only, + ] + + def get(self): + try: + query = AppPermittedListQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise UnprocessableEntity(exc.json()) + + ctx = g.auth_ctx + page_result = list_permitted_apps( + subject_email=ctx.subject_email or "", + subject_issuer=ctx.subject_issuer or "", + page=query.page, + limit=query.limit, + mode=query.mode.value if query.mode else None, + name=query.name, + ) + + if not page_result.data: + 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() + } + tenant_ids = list({a.tenant_id for a in apps_by_id.values()}) + tenants_by_id = { + str(t.id): t for t in db.session.execute(sa.select(Tenant).where(Tenant.id.in_(tenant_ids))).scalars().all() + } + + items: list[AppListRow] = [] + for r in page_result.data: + app = apps_by_id.get(r.app_id) + if not app or app.status != "normal": + # Allow-list referenced an app that no longer exists or was archived; + # filter it out rather than emit a partial row. + continue + tenant = tenants_by_id.get(str(app.tenant_id)) + items.append( + AppListRow( + id=str(app.id), + name=app.name, + description=app.description, + mode=app.mode, + tags=[], # tags are tenant-scoped; not surfaced cross-tenant + updated_at=app.updated_at.isoformat() if app.updated_at else None, + created_by_name=None, # cross-tenant author leak prevention + workspace_id=str(app.tenant_id), + workspace_name=tenant.name if tenant else None, + ) + ) + + env = PaginationEnvelope[AppListRow].build( + page=query.page, limit=query.limit, total=page_result.total, items=items + ) + return env.model_dump(mode="json"), 200 diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index 24e7028b2a..ecea39ea12 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -47,11 +47,12 @@ class Scope(StrEnum): `FULL` is the catch-all carried by `dfoa_` account tokens — it satisfies any per-route `require_scope`. `dfoe_` tokens carry the per-feature scopes - (`APPS_RUN` today). + (`APPS_RUN`, `APPS_READ_PERMITTED`). """ FULL = "full" APPS_READ = "apps:read" + APPS_READ_PERMITTED = "apps:read:permitted" APPS_RUN = "apps:run" @@ -63,6 +64,7 @@ class Accepts(StrEnum): ACCEPT_USER_ANY: frozenset[Accepts] = frozenset({Accepts.USER_ACCOUNT, Accepts.USER_EXT_SSO}) +ACCEPT_USER_EXT_SSO: frozenset[Accepts] = frozenset({Accepts.USER_EXT_SSO}) _SUBJECT_TO_ACCEPT: dict[SubjectType, Accepts] = { SubjectType.ACCOUNT: Accepts.USER_ACCOUNT, @@ -591,7 +593,7 @@ def build_registry(session_factory, redis_client) -> TokenKindRegistry: TokenKind( prefix="dfoe_", subject_type=SubjectType.EXTERNAL_SSO, - scopes=frozenset({Scope.APPS_RUN}), + scopes=frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED}), source="oauth_external_sso", resolver=oauth.for_external_sso(), ), diff --git a/api/services/enterprise/app_permitted_service.py b/api/services/enterprise/app_permitted_service.py new file mode 100644 index 0000000000..d3bc1ad371 --- /dev/null +++ b/api/services/enterprise/app_permitted_service.py @@ -0,0 +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 + +from dataclasses import dataclass + +from werkzeug.exceptions import ServiceUnavailable + + +@dataclass(frozen=True, slots=True) +class PermittedAppRow: + app_id: str + tenant_id: str + + +@dataclass(frozen=True, slots=True) +class PermittedAppsPage: + data: list[PermittedAppRow] + 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. + + 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") diff --git a/api/tests/unit_tests/controllers/openapi/test_apps_permitted_query.py b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_query.py new file mode 100644 index 0000000000..9c6306d284 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_apps_permitted_query.py @@ -0,0 +1,44 @@ +"""Unit tests for AppPermittedListQuery — the /apps/permitted query validator. + +Strict ConfigDict(extra='forbid'): cross-tenant tag/workspace_id are +unresolvable, so the model must reject them as 422 instead of silently +dropping them. Mode/name/page/limit have the same shape as AppListQuery. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from controllers.openapi.apps_permitted import AppPermittedListQuery + + +def test_query_defaults_match_apps_list(): + q = AppPermittedListQuery.model_validate({}) + assert q.page == 1 + assert q.limit == 20 + assert q.mode is None + assert q.name is None + + +def test_query_rejects_workspace_id(): + """workspace_id is meaningless for /permitted (cross-tenant); rejecting it + forces CLI authors to drop the param rather than send it silently.""" + with pytest.raises(ValidationError): + AppPermittedListQuery.model_validate({"workspace_id": "ws-1"}) + + +def test_query_rejects_tag(): + """Tags are tenant-scoped; cross-tenant tag resolution is undefined.""" + with pytest.raises(ValidationError): + AppPermittedListQuery.model_validate({"tag": "prod"}) + + +def test_query_validates_mode_against_app_mode(): + with pytest.raises(ValidationError): + AppPermittedListQuery.model_validate({"mode": "not-a-mode"}) + + +def test_query_clamps_limit_at_max(): + with pytest.raises(ValidationError): + AppPermittedListQuery.model_validate({"limit": 500}) diff --git a/api/tests/unit_tests/libs/test_oauth_bearer.py b/api/tests/unit_tests/libs/test_oauth_bearer.py new file mode 100644 index 0000000000..da93c88fc8 --- /dev/null +++ b/api/tests/unit_tests/libs/test_oauth_bearer.py @@ -0,0 +1,19 @@ +"""Unit tests for the openapi bearer-scope catalog and TokenKind registry.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + + +def test_apps_read_permitted_scope_present(): + from libs.oauth_bearer import Scope + + assert Scope.APPS_READ_PERMITTED.value == "apps:read:permitted" + + +def test_dfoe_token_kind_carries_apps_read_permitted(): + from libs.oauth_bearer import Scope, build_registry + + registry = build_registry(MagicMock(), MagicMock()) + dfoe = next(k for k in registry.kinds() if k.prefix == "dfoe_") + assert Scope.APPS_READ_PERMITTED in dfoe.scopes