From ea35ee0a3e9816ed8a0066c7a0b5095e6c6b86b2 Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 4 Mar 2026 20:33:08 -0800 Subject: [PATCH 1/5] feat: extend license enforcement to webapp API endpoints Extend license middleware to also block webapp API (/api/*) when enterprise license is expired/inactive/lost. Changes: - Check both /console/api and /api endpoints - Add webapp-specific exempt paths: - /api/passport (webapp authentication) - /api/login, /api/logout, /api/oauth - /api/forgot-password - /api/system-features (webapp needs this to check license status) This ensures both console users and webapp users are blocked when license expires, maintaining consistent enforcement across all APIs. --- api/app_factory.py | 73 +++++++++++++++++++++------------------------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index 883a30d9c9..8d9efe60ca 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -34,47 +34,42 @@ 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, ONLY allow features API - everything else is blocked + 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: + # Only exempt features endpoints - block everything else including auth + # Admin can access through admin dashboard (separate service) + if is_console_api: + is_exempt = request.path.startswith("/console/api/features") + 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 + 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") # add after request hook for injecting trace headers from OpenTelemetry span context # Only adds headers when OTEL is enabled and has valid context From 858ccd874676ab41afbd4dc35fd7a40b18ff1eab Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 4 Mar 2026 21:09:12 -0800 Subject: [PATCH 2/5] feat: add Redis caching for enterprise license status Cache license status for 10 minutes to reduce HTTP calls to enterprise API. Only caches license status, not full system features. Changes: - Add EnterpriseService.get_cached_license_status() method - Cache key: enterprise:license:status - TTL: 600 seconds (10 minutes) - Graceful degradation: falls back to API call if Redis fails Performance improvement: - Before: HTTP call (~50-200ms) on every API request - After: Redis lookup (~1ms) on cached requests - Reduces load on enterprise service by ~99% --- api/app_factory.py | 15 ++---- api/services/enterprise/enterprise_service.py | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index 8d9efe60ca..ebd5a78039 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__) @@ -50,18 +50,13 @@ def create_flask_app_with_configs() -> DifyApp: 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, - ]: + # Check license status with caching (10 min TTL) + license_status = EnterpriseService.get_cached_license_status() + if license_status in ["inactive", "expired", "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." + f"Enterprise license is {license_status}. Please contact your administrator." ) except UnauthorizedAndForceLogout: # Re-raise to let Flask error handler convert to proper JSON response 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 From 757fabda1e49d12ac3fa4065b416dce961e5ef7d Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 4 Mar 2026 22:13:25 -0800 Subject: [PATCH 3/5] fix: exempt console bootstrap APIs from license check to prevent infinite reload loop --- api/app_factory.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index ebd5a78039..09ee1ee6ee 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -35,16 +35,34 @@ def create_flask_app_with_configs() -> DifyApp: RecyclableContextVar.increment_thread_recycles() # Enterprise license validation for API endpoints (both console and webapp) - # When license expires, ONLY allow features API - everything else is blocked + # 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 if is_console_api or is_webapp_api: - # Only exempt features endpoints - block everything else including auth - # Admin can access through admin dashboard (separate service) if is_console_api: - is_exempt = request.path.startswith("/console/api/features") + # 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") @@ -53,17 +71,20 @@ def create_flask_app_with_configs() -> DifyApp: # Check license status with caching (10 min TTL) license_status = EnterpriseService.get_cached_license_status() if license_status in ["inactive", "expired", "lost"]: - # Raise UnauthorizedAndForceLogout to trigger frontend reload and logout - # Frontend checks code === 'unauthorized_and_force_logout' and calls location.reload() + # 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." + f"Enterprise license is {license_status}. " + "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 + # 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 From d81684d8d12922901a450dc6ef757c9ac862ee19 Mon Sep 17 00:00:00 2001 From: GareArc Date: Thu, 5 Mar 2026 00:27:47 -0800 Subject: [PATCH 4/5] fix: expose license status to unauthenticated /system-features callers After force-logout due to license expiry, the login page calls /system-features without auth. The license block was gated behind is_authenticated, so the frontend always saw status='none' instead of the actual expiry status. Split the guard so license.status and expired_at are always returned while workspace usage details remain auth-gated. --- api/services/feature_service.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index d94ae49d91..6993bd610c 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -361,11 +361,14 @@ class FeatureService: ) features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "") - if is_authenticated and (license_info := enterprise_info.get("License")): + # License status and expiry are always exposed so the login page can + # show the expiry UI after a force-logout (the user is unauthenticated + # at that point). Workspace usage details remain auth-gated. + if license_info := enterprise_info.get("License"): features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE)) features.license.expired_at = license_info.get("expiredAt", "") - if workspaces_info := license_info.get("workspaces"): + if is_authenticated and (workspaces_info := license_info.get("workspaces")): features.license.workspaces.enabled = workspaces_info.get("enabled", False) features.license.workspaces.limit = workspaces_info.get("limit", 0) features.license.workspaces.size = workspaces_info.get("used", 0) From 9a682f100922f89ce3dd44bab46ab203fc7eee72 Mon Sep 17 00:00:00 2001 From: GareArc Date: Thu, 5 Mar 2026 01:16:45 -0800 Subject: [PATCH 5/5] fix: use LicenseStatus enum instead of raw strings and tighten path prefix matching Replace raw license status strings with LicenseStatus enum values in app_factory.py and enterprise_service.py to prevent silent mismatches. Use trailing-slash prefixes ('/console/api/', '/api/') to avoid false matches on unrelated paths like /api-docs. --- api/app_factory.py | 10 +++++----- api/services/enterprise/base.py | 5 +---- api/services/enterprise/enterprise_service.py | 8 +++++--- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index 09ee1ee6ee..efefa7a455 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -11,6 +11,7 @@ from controllers.console.error import UnauthorizedAndForceLogout from core.logging.context import init_request_context from dify_app import DifyApp from services.enterprise.enterprise_service import EnterpriseService +from services.feature_service import LicenseStatus logger = logging.getLogger(__name__) @@ -38,8 +39,8 @@ def create_flask_app_with_configs() -> DifyApp: # 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 + is_console_api = request.path.startswith("/console/api/") + is_webapp_api = request.path.startswith("/api/") and not is_console_api if is_console_api or is_webapp_api: if is_console_api: @@ -70,14 +71,13 @@ def create_flask_app_with_configs() -> DifyApp: try: # Check license status with caching (10 min TTL) license_status = EnterpriseService.get_cached_license_status() - if license_status in ["inactive", "expired", "lost"]: + if license_status in (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.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." + f"Enterprise license is {license_status}. Please contact your administrator." ) except UnauthorizedAndForceLogout: raise diff --git a/api/services/enterprise/base.py b/api/services/enterprise/base.py index 86cca34cf2..cc29ecfdc4 100644 --- a/api/services/enterprise/base.py +++ b/api/services/enterprise/base.py @@ -101,10 +101,7 @@ class BaseRequest: # {"message": "..."} # {"detail": "..."} error_message = ( - error_data.get("message") - or error_data.get("error") - or error_data.get("detail") - or error_message + error_data.get("message") or error_data.get("error") or error_data.get("detail") or error_message ) except Exception: # If JSON parsing fails, use the default message diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index c2d89283a6..8e1da916e6 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -258,9 +258,11 @@ class EnterpriseService: info = cls.get_info() license_info = info.get("License") if license_info: - status = license_info.get("status", "inactive") + from services.feature_service import LicenseStatus + + status = license_info.get("status", LicenseStatus.INACTIVE) # Only cache valid statuses so license updates are picked up immediately - if status in ("active", "expiring"): + if status in (LicenseStatus.ACTIVE, LicenseStatus.EXPIRING): try: redis_client.setex(LICENSE_STATUS_CACHE_KEY, LICENSE_STATUS_CACHE_TTL, status) except Exception: @@ -269,4 +271,4 @@ class EnterpriseService: except Exception: logger.exception("Failed to get enterprise license status") - return None \ No newline at end of file + return None