fix(console): stop advertising the education plan outside Dify Cloud (#41898)

This commit is contained in:
L1nSn0w
2026-09-09 04:38:50 +00:00
committed by GitHub
parent f1c2917038
commit 8a69aa2884
12 changed files with 114 additions and 26 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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,
)
+5 -5
View File
@@ -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(
+4 -14
View File
@@ -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)
@@ -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: ...
+4 -1
View File
@@ -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
@@ -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
@@ -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
+15 -1
View File
@@ -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())
@@ -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
+33 -2
View File
@@ -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
+2
View File
@@ -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,
},
},