Files
dify/api/controllers/openapi/apps_permitted.py
GareArc d1c1c04615 fix(openapi): /apps/permitted hardening + naming
- fail-fast on missing subject_email/subject_issuer for dfoe_
  bearers (was silently coercing None -> empty string and sending
  a malformed query upstream).
- document the has_more contract: total comes from EE inner-API
  unfiltered count; locally-dropped archived rows leave
  len(items) < limit even when has_more=True.
- gate tenant-name lookup in /apps on non-empty rows so empty
  filter results skip the wasted scalar query.
- rename AppListPermittedApi -> AppPermittedListApi for word-order
  consistency with AppPermittedListQuery.
- tests: positive mode acceptance and explicit dfoa_ non-carrier
  assertion.
2026-05-05 21:12:33 -07:00

110 lines
4.0 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 InternalServerError, 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):
"""Strict (`extra='forbid'`) — rejects `workspace_id`/`tag`/etc. that are valid on /apps but not here."""
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 AppPermittedListApi(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
# 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:
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":
# Skip allow-list entries where the app is missing/archived.
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=[], # 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,
)
)
# total/has_more reflect the EE-side allow-list; len(items) may be < limit when local rows are dropped.
env = PaginationEnvelope[AppListRow].build(
page=query.page, limit=query.limit, total=page_result.total, items=items
)
return env.model_dump(mode="json"), 200