mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 05:56:31 +08:00
feat(openapi): /apps/permitted — external-subject app discovery (EE)
Split route for dfoe_ external-SSO discovery, separate from /apps (dfoa_-only workspace catalog). Cross-tenant allow-list query: server calls Enterprise inner-API POST /inner/api/webapp/permitted-apps and hydrates app/tenant rows locally. New scope apps:read:permitted (no dual-meaning with apps:read). Route gated by @enterprise_only — 404 on CE — and validate_bearer(accept=ACCEPT_USER_EXT_SSO) — 403 on dfoa_. Query validator rejects workspace_id and tag (cross-tenant unresolvable); mode/name supported. EE inner-API wire-up depends on ee-2; the service-layer stub raises ServiceUnavailable until that endpoint ships. CLI dispatches between /apps and /apps/permitted client-side based on the bearer prefix in hosts.yml — see docs/specs/v1.0/apps.md §Subject dispatch. Verified via unit tests on AppPermittedListQuery and Scope wiring; HTTP integration tests deferred to ee-2 once the inner-API ships.
This commit is contained in:
parent
6f3c2fe97b
commit
04ebf8a92f
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
109
api/controllers/openapi/apps_permitted.py
Normal file
109
api/controllers/openapi/apps_permitted.py
Normal file
@ -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
|
||||
@ -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(),
|
||||
),
|
||||
|
||||
44
api/services/enterprise/app_permitted_service.py
Normal file
44
api/services/enterprise/app_permitted_service.py
Normal file
@ -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")
|
||||
@ -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})
|
||||
19
api/tests/unit_tests/libs/test_oauth_bearer.py
Normal file
19
api/tests/unit_tests/libs/test_oauth_bearer.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user