mirror of
https://github.com/langgenius/dify.git
synced 2026-05-20 08:46:57 +08:00
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.
110 lines
3.9 KiB
Python
110 lines
3.9 KiB
Python
"""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
|