From 8a69aa2884ad167e9c38294131e278478ddd0b2c Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Wed, 9 Sep 2026 04:38:50 +0000 Subject: [PATCH] fix(console): stop advertising the education plan outside Dify Cloud (#41898) --- api/app_factory.py | 4 ++ api/configs/feature/__init__.py | 2 +- api/controllers/console/workspace/account.py | 10 ++--- api/libs/external_api.py | 18 ++------- api/services/account_education_service.py | 4 ++ api/services/feature_service.py | 5 ++- .../services/test_feature_service.py | 3 +- .../controllers/openapi/conftest.py | 4 +- .../unit_tests/libs/test_external_api.py | 16 +++++++- .../test_feature_service_education.py | 37 +++++++++++++++++++ api/tests/unit_tests/test_app_factory.py | 35 +++++++++++++++++- web/service/console/query-policies.ts | 2 + 12 files changed, 114 insertions(+), 26 deletions(-) create mode 100644 api/tests/unit_tests/services/test_feature_service_education.py diff --git a/api/app_factory.py b/api/app_factory.py index 2a08aeeed3e..a2021e99199 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -105,6 +105,10 @@ def create_flask_app_with_configs() -> DifyApp: dify_app = DifyApp(__name__) dify_app.config.from_mapping(dify_config.model_dump()) dify_app.config["RESTX_INCLUDE_ALL_MODELS"] = True + # flask-restx appends url-map suggestions to 404 bodies: they enumerate routes to + # anonymous callers, and a policy 404 on an existing route (edition admission) + # ends up suggesting the very path that was just requested. + dify_app.config["RESTX_ERROR_404_HELP"] = False # add before request hook @dify_app.before_request diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 3fd5cfaac6e..facc092575e 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1614,7 +1614,7 @@ class AccountConfig(BaseSettings): ) EDUCATION_ENABLED: bool = Field( - description="whether to enable education identity", + description="whether to enable education identity (CLOUD deployments only)", default=False, ) diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index d2993fc209c..81754559825 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -47,13 +47,13 @@ from controllers.console.workspace.error import ( RepeatPasswordNotMatchError, ) from controllers.console.wraps import model_validate, setup_required -from enums import DeploymentEdition from extensions.ext_application_services import application_services from fields.base import ResponseModel from fields.member_fields import AccountResponse from libs.helper import EmailStr, dump_response, extract_remote_ip, timezone, to_timestamp from machinery.context import RequestContext from services import account_errors +from services.account_education_service import EDUCATION_EDITIONS from services.entities.account_entities import AccountProfileChanges @@ -518,7 +518,7 @@ class AccountDeleteUpdateFeedbackApi(Resource): @console_ns.route("/account/education/verify") class EducationVerifyApi(Resource): @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__]) - @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @console_account_admission(editions=EDUCATION_EDITIONS) def get(self, request_context: RequestContext): try: verification = application_services().accounts.education.verify(request_context) @@ -533,7 +533,7 @@ class EducationVerifyApi(Resource): class EducationApi(Resource): @console_ns.expect(console_ns.models[EducationActivatePayload.__name__]) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__]) - @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @console_account_admission(editions=EDUCATION_EDITIONS) @model_validate(EducationActivatePayload) def post(self, args: EducationActivatePayload, request_context: RequestContext): try: @@ -550,7 +550,7 @@ class EducationApi(Resource): return dump_response(EducationActivateResponse, activation) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__]) - @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @console_account_admission(editions=EDUCATION_EDITIONS) def get(self, request_context: RequestContext): return dump_response(EducationStatusResponse, application_services().accounts.education.status(request_context)) @@ -559,7 +559,7 @@ class EducationApi(Resource): class EducationAutoCompleteApi(Resource): @console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery)) @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__]) - @console_account_admission(editions=frozenset({DeploymentEdition.CLOUD})) + @console_account_admission(editions=EDUCATION_EDITIONS) @model_validate(EducationAutocompleteQuery) def get(self, args: EducationAutocompleteQuery, request_context: RequestContext): return dump_response( diff --git a/api/libs/external_api.py b/api/libs/external_api.py index fd3c18c06a4..6843101c6f7 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -169,11 +169,10 @@ class ExternalApi(Api): kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False if error_body_formatter is not None: kwargs.setdefault("catch_all_404s", True) - # the overrides below patch private flask-restx methods; fail at - # startup (not at the first 404) if an upgrade removes them - for private_hook in ("_should_use_fr_error_handler", "_help_on_404"): - if not callable(getattr(Api, private_hook, None)): - raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides") + # the override below patches a private flask-restx method; fail at + # startup (not at the first 404) if an upgrade removes it + if not callable(getattr(Api, "_should_use_fr_error_handler", None)): + raise RuntimeError("flask-restx no longer exposes _should_use_fr_error_handler; update ExternalApi") # manual separate call on construction and init_app to ensure configs in kwargs effective super().__init__(app=None, *args, **kwargs) @@ -208,12 +207,3 @@ class ExternalApi(Api): if not prefix: return True return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/") - - @override - def _help_on_404(self, message: str | None = None) -> str | None: - # flask-restx appends route suggestions post-handler; with a canonical - # formatter installed, that would corrupt the contract and enumerate - # routes to unauthenticated callers. - if self._error_body_formatter is not None: - return message - return super()._help_on_404(message) diff --git a/api/services/account_education_service.py b/api/services/account_education_service.py index 20634fc9e34..321841f9131 100644 --- a/api/services/account_education_service.py +++ b/api/services/account_education_service.py @@ -2,6 +2,7 @@ from typing import Protocol +from enums import DeploymentEdition from machinery.context import RequestContext from services.account_errors import AccountNotFoundError, EducationRateLimitExceededError from services.account_ports import AccountRepository @@ -12,6 +13,9 @@ from services.entities.account_entities import ( AccountEducationVerification, ) +EDUCATION_EDITIONS: frozenset[DeploymentEdition] = frozenset({DeploymentEdition.CLOUD}) +"""Editions running the education program: the Console admission gate and `education.enabled` share it.""" + class AccountEducationRateLimiter(Protocol): def is_rate_limited(self, key: str, /) -> bool: ... diff --git a/api/services/feature_service.py b/api/services/feature_service.py index eda3577a64d..ee9c896c067 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -1,5 +1,6 @@ from configs import dify_config from enums import CloudPlan, DeploymentEdition, HostedTrialProvider +from services.account_education_service import EDUCATION_EDITIONS from services.billing_service import BillingInfo, BillingService from services.enterprise.enterprise_service import EnterpriseService from services.entities import feature_entities @@ -112,7 +113,9 @@ class FeatureService: def _fulfill_params_from_env(cls, features: feature_entities.FeatureModel): features.can_replace_logo = dify_config.CAN_REPLACE_LOGO features.model_load_balancing_enabled = dify_config.MODEL_LB_ENABLED - features.education.enabled = dify_config.EDUCATION_ENABLED + features.education.enabled = ( + dify_config.EDUCATION_ENABLED and dify_config.DEPLOYMENT_EDITION in EDUCATION_EDITIONS + ) features.enable_skill = dify_config.ENABLE_SKILL @classmethod diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index 0ea4ac3a058..bc939bbab9f 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -499,7 +499,8 @@ class TestFeatureService: # Verify environment-based features assert result.can_replace_logo is True assert result.model_load_balancing_enabled is True - assert result.education.enabled is True + # CLOUD-only: the Console education endpoints 404 on every other edition. + assert result.education.enabled is False # Verify default limitations assert result.members.size == 0 diff --git a/api/tests/unit_tests/controllers/openapi/conftest.py b/api/tests/unit_tests/controllers/openapi/conftest.py index 9f60b5979a4..6d17dcaa39c 100644 --- a/api/tests/unit_tests/controllers/openapi/conftest.py +++ b/api/tests/unit_tests/controllers/openapi/conftest.py @@ -3,6 +3,7 @@ import uuid import pytest from flask import Flask +from app_factory import create_flask_app_with_configs from controllers.openapi import bp as openapi_bp from controllers.openapi.auth.data import AuthData from controllers.openapi.auth.pipeline import PipelineRouter @@ -50,7 +51,8 @@ def bypass_pipeline(monkeypatch: pytest.MonkeyPatch): @pytest.fixture def openapi_app(): - app = Flask(__name__) + # the real factory: flask-restx wire behaviour (404 route suggestions) is app config + app = create_flask_app_with_configs() app.config["TESTING"] = True app.register_blueprint(openapi_bp) return app diff --git a/api/tests/unit_tests/libs/test_external_api.py b/api/tests/unit_tests/libs/test_external_api.py index 77ebe6fe351..f2f7d84f0a6 100644 --- a/api/tests/unit_tests/libs/test_external_api.py +++ b/api/tests/unit_tests/libs/test_external_api.py @@ -1,5 +1,6 @@ +import pytest from flask import Blueprint, Flask -from flask_restx import Resource +from flask_restx import Api, Resource from werkzeug.exceptions import BadRequest, Unauthorized from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN @@ -221,3 +222,16 @@ def test_unauthorized_and_force_logout_clears_cookies(): assert COOKIE_NAME_ACCESS_TOKEN in cookie_names_found assert COOKIE_NAME_CSRF_TOKEN in cookie_names_found assert COOKIE_NAME_REFRESH_TOKEN in cookie_names_found + + +class _PassthroughFormatter: + def finalize(self, _e: Exception, data: dict[str, object], _status_code: int) -> dict[str, object]: + return data + + +def test_missing_flask_restx_private_hook_fails_at_startup(monkeypatch: pytest.MonkeyPatch): + """The guard exists so a flask-restx upgrade breaks construction, not the first 404.""" + monkeypatch.delattr(Api, "_should_use_fr_error_handler") + + with pytest.raises(RuntimeError, match="_should_use_fr_error_handler"): + ExternalApi(Blueprint("guard", __name__), error_body_formatter=_PassthroughFormatter()) diff --git a/api/tests/unit_tests/services/test_feature_service_education.py b/api/tests/unit_tests/services/test_feature_service_education.py new file mode 100644 index 00000000000..7eb04e2e4f4 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_education.py @@ -0,0 +1,37 @@ +from collections.abc import Callable +from unittest.mock import patch + +import pytest + +from enums import DeploymentEdition +from services.account_education_service import EDUCATION_EDITIONS +from services.entities.feature_entities import FeatureModel +from services.feature_service import FeatureService + + +def test_education_feature_is_disabled_by_default() -> None: + assert FeatureModel().education.enabled is False + + +@pytest.mark.parametrize("edition", list(DeploymentEdition)) +def test_education_is_advertised_only_where_its_endpoints_exist( + config_overrides: Callable[..., None], edition: DeploymentEdition +) -> None: + """The frontend gates its /account/education* calls on this flag, and the admission + decorator answers 404 on every edition outside EDUCATION_EDITIONS.""" + config_overrides(DEPLOYMENT_EDITION=edition, EDUCATION_ENABLED=True) + + with patch("services.feature_service.EnterpriseService.get_workspace_info", return_value={}): + features = FeatureService.get_features("") + + assert features.education.enabled is (edition in EDUCATION_EDITIONS) + + +def test_education_stays_disabled_on_cloud_without_the_env_flag( + config_overrides: Callable[..., None], +) -> None: + config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, EDUCATION_ENABLED=False) + + features = FeatureService.get_features("") + + assert features.education.enabled is False diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py index 64f0dc230f5..fc946e1ec04 100644 --- a/api/tests/unit_tests/test_app_factory.py +++ b/api/tests/unit_tests/test_app_factory.py @@ -1,9 +1,13 @@ -"""Enterprise license gating performed by the global ``before_request`` hook.""" +"""Behaviour the Flask application factory installs app-wide. + +Enterprise license gating through the global ``before_request`` hook, and the +flask-restx defaults every API surface inherits from the app config. +""" from unittest.mock import patch import pytest -from flask import Blueprint, Flask +from flask import Blueprint, Flask, abort from flask_restx import Resource from app_factory import create_flask_app_with_configs @@ -318,3 +322,30 @@ class TestSessionSurfaceLicenseGate: response = gated_app.test_client().get("/health") assert response.status_code == 200 + + +class TestRestxRoute404Help: + """flask-restx appends url-map suggestions to 404 bodies unless the factory opts out.""" + + @pytest.fixture + def gated_route_app(self) -> Flask: + app = create_flask_app_with_configs() + bp = Blueprint("console_test", __name__, url_prefix="/console/api") + api = ExternalApi(bp) + + # An admission gate (edition, license) answers 404 on a route that exists. + @api.route("/account/education") + class EducationGated(Resource): + def get(self): + abort(404) + + app.register_blueprint(bp) + return app + + def test_404_body_carries_no_route_suggestions(self, gated_route_app: Flask): + response = gated_route_app.test_client().get("/console/api/account/education") + + assert response.status_code == 404 + message = response.get_json()["message"] + assert "did you mean" not in message.lower() + assert "/console/api/account/education" not in message diff --git a/web/service/console/query-policies.ts b/web/service/console/query-policies.ts index 7b9d1677450..a972b6af987 100644 --- a/web/service/console/query-policies.ts +++ b/web/service/console/query-policies.ts @@ -17,6 +17,8 @@ export function createConsoleQuery(consoleClient: ConsoleClient) { education: { get: { queryOptions: { + // Passive probe: every callsite degrades to "not a student", so no toast. + context: { silent: true }, retry: false, }, },