From fa6f4b0ea5fc00306bbb765f8f5e0ed536b793e0 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:48:21 -0700 Subject: [PATCH] refactor(api): enforce enums over string literals on openapi surface (#38009) --- api/controllers/openapi/apps.py | 9 ++++++--- api/controllers/openapi/apps_permitted_external.py | 3 ++- api/controllers/openapi/auth/data.py | 10 +++++++--- api/controllers/openapi/auth/prepare.py | 14 +++++++------- api/controllers/openapi/auth/verify.py | 4 ++-- api/controllers/openapi/human_input_form.py | 4 ++-- api/controllers/openapi/workflow_events.py | 4 ++-- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/api/controllers/openapi/apps.py b/api/controllers/openapi/apps.py index 181af5c0742..8d5c9670e77 100644 --- a/api/controllers/openapi/apps.py +++ b/api/controllers/openapi/apps.py @@ -25,12 +25,13 @@ from controllers.openapi._models import ( AppListRow, ) from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, RBACRequirement +from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement from controllers.service_api.app.error import AppUnavailableError from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from models import App +from models.enums import AppStatus from models.model import AppMode from services.account_service import TenantService from services.app_service import AppListParams, AppService @@ -166,7 +167,9 @@ class AppListApi(Resource): # an empty set or list means the caller has no accessible apps. # End-users bypass RBAC here — their access is controlled by scope upstream. apply_rbac_filter = ( - dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None + dify_config.RBAC_ENABLED + and auth_data.caller_kind != CallerKind.END_USER + and auth_data.account_id is not None ) access_filter = AppAccessFilter.unrestricted() if apply_rbac_filter: @@ -203,7 +206,7 @@ class AppListApi(Resource): limit=query.limit, mode=query.mode.value if query.mode else "all", # type:ignore name=query.name, - status="normal", + status=AppStatus.NORMAL, # Visibility gate pushed into the query — pagination.total stays # consistent across pages because invisible rows never count. openapi_visible=True, diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index 9bc400e5cc7..718d3dbd169 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -25,6 +25,7 @@ from controllers.openapi.auth.data import AuthData, Edition from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from models import App +from models.enums import AppStatus from services.account_service import TenantService from services.app_service import AppService from services.enterprise.app_permitted_service import list_permitted_apps @@ -62,7 +63,7 @@ class PermittedExternalAppsListApi(Resource): items: list[AppListRow] = [] for app_id in page_result.app_ids: app = apps_by_id.get(app_id) - if not app or app.status != "normal": + if not app or app.status != AppStatus.NORMAL: continue tenant = tenants_by_id.get(str(app.tenant_id)) items.append( diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index 9aefef0061c..fa68b48d975 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -2,7 +2,6 @@ from __future__ import annotations import uuid from enum import StrEnum -from typing import Literal from pydantic import BaseModel, ConfigDict, Field from werkzeug.exceptions import InternalServerError @@ -21,6 +20,11 @@ class Edition(StrEnum): SAAS = "saas" +class CallerKind(StrEnum): + ACCOUNT = "account" + END_USER = "end_user" + + def current_edition() -> Edition: if dify_config.EDITION == "CLOUD": return Edition.SAAS @@ -78,9 +82,9 @@ class AuthData(BaseModel): tenant_role: TenantAccountRole | None = None caller: Account | EndUser | None = None - caller_kind: Literal["account", "end_user"] | None = None + caller_kind: CallerKind | None = None - def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]: + def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]: if self.app is None or self.caller is None or self.caller_kind is None: raise InternalServerError("pipeline_invariant_violated: app context missing") return self.app, self.caller, self.caller_kind diff --git a/api/controllers/openapi/auth/prepare.py b/api/controllers/openapi/auth/prepare.py index afded64702d..6704b27decc 100644 --- a/api/controllers/openapi/auth/prepare.py +++ b/api/controllers/openapi/auth/prepare.py @@ -5,10 +5,10 @@ import uuid from flask import request from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, CallerKind from extensions.ext_database import db -from models.account import TenantStatus -from models.enums import EndUserType +from models.account import AccountStatus, TenantStatus +from models.enums import AppStatus, EndUserType from services.account_service import AccountService, TenantService from services.app_service import AppService from services.end_user_service import EndUserService @@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None: except ValueError: raise NotFound("app not found") app = AppService.get_app_by_id(db.session, app_id) - if not app or app.status != "normal": + if not app or app.status != AppStatus.NORMAL: raise NotFound("app not found") data.app = app @@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None: if data.tenant: account.current_tenant = data.tenant data.caller = account - data.caller_kind = "account" + data.caller_kind = CallerKind.ACCOUNT def load_workspace_role(data: AuthData) -> None: @@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None: return if data.tenant is None or data.account_id is None: return - if data.caller is not None and getattr(data.caller, "status", None) != "active": + if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE: return role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id)) if role is None: @@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None: user_id=data.external_identity.email, ) data.caller = end_user - data.caller_kind = "end_user" + data.caller_kind = CallerKind.END_USER def load_app_access_mode(data: AuthData) -> None: diff --git a/api/controllers/openapi/auth/verify.py b/api/controllers/openapi/auth/verify.py index 1323d142fb1..b5f10f66b34 100644 --- a/api/controllers/openapi/auth/verify.py +++ b/api/controllers/openapi/auth/verify.py @@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity from configs import dify_config from controllers.common.wraps import enforce_rbac_access -from controllers.openapi.auth.data import AuthData +from controllers.openapi.auth.data import AuthData, CallerKind from extensions.ext_database import db from libs.oauth_bearer import Scope, TokenType from services.account_service import AccountService, TenantService @@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None: if not dify_config.RBAC_ENABLED: return # Only account callers are subject to RBAC; end_user access is scope-controlled. - if data.caller_kind != "account": + if data.caller_kind != CallerKind.ACCOUNT: return if data.account_id is None or data.tenant is None: raise Forbidden("rbac context missing") diff --git a/api/controllers/openapi/human_input_form.py b/api/controllers/openapi/human_input_form.py index 223f748613b..998dd669836 100644 --- a/api/controllers/openapi/human_input_form.py +++ b/api/controllers/openapi/human_input_form.py @@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, RBACRequirement +from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement from core.workflow.human_input_policy import ( HumanInputSurface, is_recipient_type_allowed_for_surface, @@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource): submission_user_id: str | None = None submission_end_user_id: str | None = None - if caller_kind == "account": + if caller_kind == CallerKind.ACCOUNT: submission_user_id = caller.id else: submission_end_user_id = caller.id diff --git a/api/controllers/openapi/workflow_events.py b/api/controllers/openapi/workflow_events.py index 916c93707dd..1a2bef28772 100644 --- a/api/controllers/openapi/workflow_events.py +++ b/api/controllers/openapi/workflow_events.py @@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, RBACRequirement +from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter @@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource): if workflow_run.app_id != app_model.id: raise NotFound("Workflow run not found") - if caller_kind == "account": + if caller_kind == CallerKind.ACCOUNT: if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id: raise NotFound("Workflow run not found") else: