mirror of
https://github.com/langgenius/dify.git
synced 2026-09-28 06:13:22 +08:00
fix(console): stop advertising the education plan outside Dify Cloud (#41898)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user