diff --git a/api/app_factory.py b/api/app_factory.py index 883a30d9c9..09ee1ee6ee 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -10,7 +10,7 @@ from contexts.wrapper import RecyclableContextVar from controllers.console.error import UnauthorizedAndForceLogout from core.logging.context import init_request_context from dify_app import DifyApp -from services.feature_service import FeatureService, LicenseStatus +from services.enterprise.enterprise_service import EnterpriseService logger = logging.getLogger(__name__) @@ -34,47 +34,58 @@ def create_flask_app_with_configs() -> DifyApp: init_request_context() RecyclableContextVar.increment_thread_recycles() - # Enterprise license validation for console API endpoints - if dify_config.ENTERPRISE_ENABLED and request.path.startswith("/console/api"): - # Skip license check for auth-related endpoints and system endpoints - exempt_paths = [ - "/console/api/login", - "/console/api/logout", - "/console/api/oauth", - "/console/api/setup", - "/console/api/init", - "/console/api/forgot-password", - "/console/api/email-code-login", - "/console/api/activation", - "/console/api/data-source-oauth", - "/console/api/features", # Allow fetching features to show license status - ] + # Enterprise license validation for API endpoints (both console and webapp) + # When license expires, block all API access except bootstrap endpoints needed + # for the frontend to load the license expiration page without infinite reloads. + if dify_config.ENTERPRISE_ENABLED: + is_console_api = request.path.startswith("/console/api") + is_webapp_api = request.path.startswith("/api") and not is_console_api - # Check if current path is exempt - is_exempt = any(request.path.startswith(path) for path in exempt_paths) + if is_console_api or is_webapp_api: + if is_console_api: + # Console bootstrap APIs exempt from license check: + # - system-features: license status for expiry UI (GlobalPublicStoreProvider) + # - setup: install/setup status check (AppInitializer) + # - features: billing/plan features (ProviderContextProvider) + # - account/profile: login check + user profile (AppContextProvider, useIsLogin) + # - workspaces/current: workspace + model providers (AppContextProvider) + # - version: version check (AppContextProvider) + # - activate/check: invitation link validation (signin page) + # Without these exemptions, the signin page triggers location.reload() + # on unauthorized_and_force_logout, causing an infinite loop. + console_exempt_prefixes = ( + "/console/api/system-features", + "/console/api/setup", + "/console/api/features", + "/console/api/account/profile", + "/console/api/workspaces/current", + "/console/api/version", + "/console/api/activate/check", + ) + is_exempt = any(request.path.startswith(p) for p in console_exempt_prefixes) + else: # webapp API + is_exempt = request.path.startswith("/api/system-features") - if not is_exempt: - try: - # Check license status - system_features = FeatureService.get_system_features(is_authenticated=True) - if system_features.license.status in [ - LicenseStatus.INACTIVE, - LicenseStatus.EXPIRED, - LicenseStatus.LOST, - ]: - # Raise UnauthorizedAndForceLogout to trigger frontend reload and logout - # Frontend checks code === 'unauthorized_and_force_logout' and calls location.reload() - raise UnauthorizedAndForceLogout( - f"Enterprise license is {system_features.license.status.value}. " - "Please contact your administrator." - ) - except UnauthorizedAndForceLogout: - # Re-raise to let Flask error handler convert to proper JSON response - raise - except Exception: - # If license check fails, log but don't block the request - # This prevents service disruption if enterprise API is temporarily unavailable - logger.exception("Failed to check enterprise license status") + if not is_exempt: + try: + # Check license status with caching (10 min TTL) + license_status = EnterpriseService.get_cached_license_status() + if license_status in ["inactive", "expired", "lost"]: + # Cookie clearing is handled by register_external_error_handlers + # in libs/external_api.py which detects the error code and calls + # build_force_logout_cookie_headers(). Frontend then checks + # code === 'unauthorized_and_force_logout' and calls location.reload(). + raise UnauthorizedAndForceLogout( + f"Enterprise license is {license_status}. " + "Please contact your administrator." + ) + except UnauthorizedAndForceLogout: + raise + except Exception: + # If license check fails, log but don't block the request. + # This prevents service disruption if enterprise API is temporarily + # unavailable. + logger.exception("Failed to check enterprise license status") # add after request hook for injecting trace headers from OpenTelemetry span context # Only adds headers when OTEL is enabled and has valid context diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 7a6ebf7c3f..c2d89283a6 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -5,12 +5,16 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config +from extensions.ext_redis import redis_client from services.enterprise.base import EnterpriseRequest logger = logging.getLogger(__name__) DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0 ALLOWED_ACCESS_MODES = ["public", "private", "private_all", "sso_verified"] +# License status cache configuration +LICENSE_STATUS_CACHE_KEY = "enterprise:license:status" +LICENSE_STATUS_CACHE_TTL = 600 # 10 minutes class WebAppSettings(BaseModel): @@ -224,3 +228,45 @@ class EnterpriseService: params = {"appId": app_id} EnterpriseRequest.send_request("DELETE", "/webapp/clean", params=params) + + @classmethod + def get_cached_license_status(cls): + """ + Get enterprise license status with Redis caching to reduce HTTP calls. + + Only caches valid statuses (active/expiring) since invalid statuses + should be re-checked every request — the admin may update the license + at any time. + + Returns license status string or None if unavailable. + """ + if not dify_config.ENTERPRISE_ENABLED: + return None + + # Try cache first — only valid statuses are cached + try: + cached_status = redis_client.get(LICENSE_STATUS_CACHE_KEY) + if cached_status: + if isinstance(cached_status, bytes): + cached_status = cached_status.decode("utf-8") + return cached_status + except Exception: + logger.debug("Failed to get license status from cache, calling enterprise API") + + # Cache miss or failure — call enterprise API + try: + info = cls.get_info() + license_info = info.get("License") + if license_info: + status = license_info.get("status", "inactive") + # Only cache valid statuses so license updates are picked up immediately + if status in ("active", "expiring"): + try: + redis_client.setex(LICENSE_STATUS_CACHE_KEY, LICENSE_STATUS_CACHE_TTL, status) + except Exception: + logger.debug("Failed to cache license status") + return status + except Exception: + logger.exception("Failed to get enterprise license status") + + return None \ No newline at end of file