refactor: remove backend-only system feature fields (#39543)

This commit is contained in:
yyh 2026-07-25 10:01:12 +08:00 committed by GitHub
parent 58f83fa7e7
commit aff5c47541
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 267 additions and 366 deletions

View File

@ -213,6 +213,17 @@ Before opening a PR / submitting:
DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`,
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
### System Features Contract
- Treat the shared Console/Web `/system-features` response as a minimal unauthenticated bootstrap allowlist, not a
general configuration or feature-discovery endpoint. Existing fields do not establish precedent.
- Before adding a field, read `controllers/API_SCHEMA_GUIDE.md#public-system-features-contract` and provide evidence
that both Console and Web have production consumers that require it before authentication.
- Never place backend-only policy, surface-specific configuration, post-authentication state, speculative values, or
large/slow payloads in `SystemFeatureModel`. Use the consumer or domain owner described in the schema guide.
- Agents and reviewers must reject additions whose owner, public exposure, pre-authentication need, or root SSR cost
is not explicit.
### Miscellaneous
- Use `configs.dify_config` for configuration—never read environment variables directly.

View File

@ -12,6 +12,47 @@ parameters, response schemas, and Swagger documentation.
- Do not add new Flask-RESTX `fields.*` dictionaries, `Namespace.model(...)` exports, or `@marshal_with(...)` for migrated or new endpoints.
- Do not use `@ns.expect(...)` for GET query parameters. Flask-RESTX documents that as a request body.
## Public System Features Contract
The Console and Web `/system-features` endpoints share `SystemFeatureModel`. They are unauthenticated and may be
requested during root SSR, so treat this response as a minimal public bootstrap allowlist. It is not a general
configuration endpoint, a feature registry, or a mirror of environment and Enterprise settings. Existing fields are
legacy inventory and do not establish precedent for new fields.
A new field is eligible only when all of the following are true:
1. Both Console and Web have named production consumers for the field.
2. Both consumers need the value before authentication and tenant/workspace bootstrap to render initial state or
choose an authentication flow.
3. The value varies at runtime or by deployment and cannot be safely derived from an existing public contract.
4. The value is non-sensitive, safe to disclose without authentication, and has stable public API semantics.
5. Sending the value on every root bootstrap is demonstrably clearer and cheaper than a consumer-owned query.
Do not add:
- Backend-only policy or enforcement inputs, including security decisions, upload limits, or integration toggles.
- Console-only or Web-only configuration.
- Tenant, workspace, account, permission, billing-detail, or other post-authentication state.
- Provider payloads, operational diagnostics, large nested objects, or values without active consumers.
- Speculative fields added for possible future use.
Route excluded values to their actual owner:
- Keep backend enforcement behind a narrow service method or domain policy.
- Serve post-authentication state from an authenticated domain endpoint.
- Serve surface-specific bootstrap state from a Console- or Web-specific endpoint and account for its SSR, caching,
and failure cost explicitly.
- Load large, slow, or page-specific data lazily through a consumer-owned query.
Every pull request that adds a System Features field must:
- Name both production consumer paths and explain why they require the value before authentication.
- Document the root SSR request, payload, caching, and failure-mode impact.
- Update the Pydantic owner, regenerate OpenAPI Markdown and TypeScript/Zod contracts, and update shared fixtures.
- Add Console and Web schema regression coverage. Do not hand-edit generated contracts or add compatibility defaults.
Reviewers should reject a field when its owner, pre-authentication need, or consumers are unclear.
## Naming
- Request body models: use a `Payload` suffix.

View File

@ -198,6 +198,6 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account, session=db.session())
and FeatureService.get_system_features().is_allow_create_workspace
and FeatureService.is_workspace_creation_allowed()
):
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -163,7 +163,7 @@ class LoginApi(Resource):
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
FeatureService.get_system_features().is_allow_create_workspace
FeatureService.is_workspace_creation_allowed()
and not FeatureService.get_license().workspaces.is_available()
):
raise WorkspacesLimitExceeded()
@ -314,7 +314,7 @@ class EmailCodeLoginApi(Resource):
workspaces = FeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.get_system_features().is_allow_create_workspace:
if not FeatureService.is_workspace_creation_allowed():
raise NotAllowedCreateWorkspace()
else:
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -292,7 +292,7 @@ def _generate_account(
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
if not FeatureService.get_system_features().is_allow_create_workspace:
if not FeatureService.is_workspace_creation_allowed():
raise WorkSpaceNotAllowedCreateError()
else:
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -123,24 +123,22 @@ class AppDslVersionApi(Resource):
@console_ns.route("/system-features")
class SystemFeatureApi(Resource):
@console_ns.doc("get_system_features")
@console_ns.doc(description="Get system-wide feature configuration")
@console_ns.doc(
description="Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. "
"This is not a general feature registry."
)
@console_ns.response(
200,
"Success",
console_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get system-wide feature configuration
"""Get the non-sensitive bootstrap snapshot exposed before authentication.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for dashboard initialization.
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint. Authenticated
license detail is served separately by SystemFeatureLicenseApi.
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
"""
return FeatureService.get_system_features().model_dump()
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
@console_ns.route("/system-features/license")

View File

@ -2,6 +2,7 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from libs.helper import dump_response
from services.feature_service import FeatureService, SystemFeatureModel
register_response_schema_models(web_ns, SystemFeatureModel)
@ -10,7 +11,10 @@ register_response_schema_models(web_ns, SystemFeatureModel)
@web_ns.route("/system-features")
class SystemFeatureApi(Resource):
@web_ns.doc("get_system_features")
@web_ns.doc(description="Get system feature flags and configuration")
@web_ns.doc(
description="Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. "
"This is not a general feature registry."
)
@web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"})
@web_ns.response(
200,
@ -18,22 +22,11 @@ class SystemFeatureApi(Resource):
web_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get system feature flags and configuration.
Returns the current system feature flags and configuration
that control various functionalities across the platform.
Returns:
dict: System feature configuration object
"""Get the non-sensitive bootstrap snapshot exposed before authentication.
This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,
except it is intended for use by the web app, instead of the console dashboard.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for webapp initialization.
Authentication would create circular dependency (can't authenticate without webapp loading).
Only non-sensitive configuration data should be returned by this endpoint.
Authentication configuration must be available before the authentication flow can be selected.
"""
return FeatureService.get_system_features().model_dump()
return dump_response(SystemFeatureModel, FeatureService.get_system_features())

View File

@ -71,7 +71,7 @@ def check_credential_policy_compliance(
)
from services.feature_service import FeatureService
if not FeatureService.get_system_features().plugin_manager.enabled or not credential_id:
if not FeatureService.is_plugin_manager_enabled() or not credential_id:
return
# Check if credential exists in database first (if requested)

View File

@ -9475,16 +9475,11 @@ Used for frontend component type mapping
| 200 | Success | **application/json**: [SchemaDefinitionsResponse](#schemadefinitionsresponse)<br> |
### [GET] /system-features
**Get system-wide feature configuration**
**Get the non-sensitive bootstrap snapshot exposed before authentication**
Get system-wide feature configuration
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for dashboard initialization.
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint. Authenticated
license detail is served separately by SystemFeatureLicenseApi.
Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
#### Responses
@ -20521,12 +20516,6 @@ Shared permission levels for resources (datasets, credentials, etc.)
| plugins | [ [PluginEntity](#pluginentity) ] | | Yes |
| total | integer | | Yes |
#### PluginManagerModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### PluginManifestResponse
| Name | Type | Description | Required |
@ -22052,6 +22041,8 @@ Model class for provider system configuration response.
#### SystemFeatureModel
Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
@ -22068,14 +22059,11 @@ Model class for provider system configuration response.
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
| rbac_enabled | boolean | | Yes |
| sso_enforced_for_signin | boolean | | Yes |
| sso_enforced_for_signin_protocol | string | | Yes |

View File

@ -774,24 +774,13 @@ Retrieve app site information and configuration.
| 500 | Internal Server Error | |
### [GET] /system-features
**Get system feature flags and configuration**
Get system feature flags and configuration
Returns the current system feature flags and configuration
that control various functionalities across the platform.
Returns:
dict: System feature configuration object
**Get the non-sensitive bootstrap snapshot exposed before authentication**
Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,
except it is intended for use by the web app, instead of the console dashboard.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for webapp initialization.
Authentication would create circular dependency (can't authenticate without webapp loading).
Only non-sensitive configuration data should be returned by this endpoint.
Authentication configuration must be available before the authentication flow can be selected.
#### Responses
@ -1412,12 +1401,6 @@ Form input definition.
| ---- | ---- | ----------- | -------- |
| PluginInstallationScope | string | | |
#### PluginManagerModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### RemoteFileInfo
| Name | Type | Description | Required |
@ -1557,6 +1540,8 @@ Default configuration for form inputs.
#### SystemFeatureModel
Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
@ -1573,14 +1558,11 @@ Default configuration for form inputs.
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
| rbac_enabled | boolean | | Yes |
| sso_enforced_for_signin | boolean | | Yes |
| sso_enforced_for_signin_protocol | string | | Yes |

View File

@ -1258,11 +1258,7 @@ class TenantService:
session: Session,
) -> Tenant:
"""Create tenant"""
if (
not FeatureService.get_system_features().is_allow_create_workspace
and not is_setup
and not is_from_dashboard
):
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
from controllers.console.error import NotAllowedCreateWorkspace
raise NotAllowedCreateWorkspace()
@ -1325,11 +1321,7 @@ class TenantService:
owner. It persists the legacy membership before creating the matching
RBAC role binding, then makes the workspace current for the account.
"""
if (
not FeatureService.get_system_features().is_allow_create_workspace
and not is_setup
and not is_from_dashboard
):
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_license().workspaces
@ -2010,7 +2002,7 @@ class RegisterService:
AccountService.link_account_integrate(provider, open_id, account, session=session)
if (
FeatureService.get_system_features().is_allow_create_workspace
FeatureService.is_workspace_creation_allowed()
and create_workspace_required
and FeatureService.get_license().workspaces.is_available()
):

View File

@ -161,30 +161,25 @@ class KnowledgeRateLimitModel(FeatureResponseModel):
subscription_plan: str = ""
class PluginManagerModel(FeatureResponseModel):
enabled: bool = False
class SystemFeatureModel(FeatureResponseModel):
"""Non-sensitive bootstrap snapshot exposed before Console or Web authentication."""
deployment_edition: DeploymentEdition
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE
enable_email_code_login: bool = False
enable_email_password_login: bool = True
enable_social_oauth_login: bool = False
enable_collaboration_mode: bool = True
is_allow_register: bool = False
is_allow_create_workspace: bool = False
is_email_setup: bool = False
license: LicenseStatusModel = LicenseStatusModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
plugin_manager: PluginManagerModel = PluginManagerModel()
enable_creators_platform: bool = False
enable_trial_app: bool = False
enable_explore_banner: bool = False
@ -266,7 +261,6 @@ class FeatureService:
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
system_features.plugin_manager.enabled = True
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
@ -277,6 +271,21 @@ class FeatureService:
return system_features
@classmethod
def is_workspace_creation_allowed(cls) -> bool:
"""Resolve the backend workspace-creation policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_CREATE_WORKSPACE
if not dify_config.ENTERPRISE_ENABLED:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed))
@classmethod
def is_plugin_manager_enabled(cls) -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.ENTERPRISE_ENABLED
@classmethod
def get_license(cls) -> LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
@ -299,7 +308,6 @@ class FeatureService:
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
@ -465,9 +473,6 @@ class FeatureService:
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "IsAllowCreateWorkspace" in enterprise_info:
features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]

View File

@ -542,7 +542,7 @@ class WorkflowService:
# Validate credentials before publishing, for credential policy check
from services.feature_service import FeatureService
if FeatureService.get_system_features().plugin_manager.enabled:
if FeatureService.is_plugin_manager_enabled():
self._validate_workflow_credentials(draft_workflow, session=session)
# validate graph structure

View File

@ -61,7 +61,7 @@ def add_tenant_for_account(
) -> Tenant:
"""Create an additional tenant and join ``account`` to it (real service calls)."""
with patch("services.account_service.FeatureService") as mock_feature_service:
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
tenant = TenantService.create_tenant(name=name, session=session)
TenantService.create_tenant_member(tenant, account, session, role=role)
return tenant

View File

@ -37,7 +37,7 @@ class TestAccountService:
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
@ -401,9 +401,7 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -436,9 +434,7 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(WorkSpaceNotAllowedCreateError):
@ -462,9 +458,7 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = False
@ -1274,7 +1268,7 @@ class TestTenantService:
patch("services.account_service.BillingService") as mock_billing_service,
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
@ -1291,9 +1285,7 @@ class TestTenantService:
fake = Faker()
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1312,9 +1304,7 @@ class TestTenantService:
fake = Faker()
tenant_name = fake.company()
# Setup mocks to disable workspace creation
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
with pytest.raises(NotAllowedCreateWorkspace): # NotAllowedCreateWorkspace exception
TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1328,9 +1318,7 @@ class TestTenantService:
fake = Faker()
custom_tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
# Create tenant with setup flag (should bypass workspace creation restriction)
tenant = TenantService.create_tenant(
@ -1354,9 +1342,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1390,9 +1376,7 @@ class TestTenantService:
name2 = fake.name()
password2 = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1430,9 +1414,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1465,9 +1447,7 @@ class TestTenantService:
tenant1_name = fake.company()
tenant2_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account and tenants
account = AccountService.create_account(
@ -1504,9 +1484,7 @@ class TestTenantService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(
@ -1542,9 +1520,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account without setting current tenant
account = AccountService.create_account(
@ -1570,9 +1546,7 @@ class TestTenantService:
tenant1_name = fake.company()
tenant2_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account and tenants
account = AccountService.create_account(
@ -1610,9 +1584,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account
account = AccountService.create_account(
@ -1639,9 +1611,7 @@ class TestTenantService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(
@ -1670,9 +1640,7 @@ class TestTenantService:
admin_name = fake.name()
admin_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1717,9 +1685,7 @@ class TestTenantService:
tenant_name = fake.company()
invalid_role = fake.word()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1738,9 +1704,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1775,9 +1739,7 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1818,9 +1780,7 @@ class TestTenantService:
password = generate_valid_password(fake)
invalid_action = "invalid_action_that_doesnt_exist"
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1853,9 +1813,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1891,9 +1849,7 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -1981,9 +1937,7 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2017,9 +1971,7 @@ class TestTenantService:
non_member_name = fake.name()
non_member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2060,9 +2012,7 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2113,9 +2063,7 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2174,9 +2122,7 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2214,9 +2160,7 @@ class TestTenantService:
tenant2_name = fake.company()
tenant3_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create multiple tenants
tenant1 = TenantService.create_tenant(name=tenant1_name, session=db_session_with_containers)
@ -2241,9 +2185,7 @@ class TestTenantService:
password = generate_valid_password(fake)
workspace_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -2282,9 +2224,7 @@ class TestTenantService:
existing_tenant_name = fake.company()
new_workspace_name = fake.company()
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -2325,9 +2265,7 @@ class TestTenantService:
password = generate_valid_password(fake)
workspace_name = fake.company()
# Setup mocks to disable workspace creation
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
# Create account
account = AccountService.create_account(
@ -2360,9 +2298,7 @@ class TestTenantService:
normal_name = fake.name()
normal_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2429,9 +2365,7 @@ class TestTenantService:
normal_name = fake.name()
normal_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2480,9 +2414,7 @@ class TestTenantService:
theme = fake.random_element(elements=("dark", "light"))
language = fake.random_element(elements=("zh-CN", "en-US"))
# Setup mocks
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
# Create tenant with custom config
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@ -2515,7 +2447,7 @@ class TestRegisterService:
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
@ -2629,9 +2561,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -2673,9 +2603,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -2722,9 +2650,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -2768,9 +2694,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# with pytest.raises(AccountRegisterError, match="Workspace is not allowed to create."):
@ -2807,9 +2731,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = False
@ -2886,9 +2808,7 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True

View File

@ -285,7 +285,6 @@ class TestFeatureService:
mock_config.ALLOW_REGISTER = False
mock_config.ALLOW_CREATE_WORKSPACE = False
mock_config.MAIL_TYPE = "smtp"
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the method under test
result = FeatureService.get_system_features()
@ -309,7 +308,6 @@ class TestFeatureService:
assert result.enable_email_password_login is False
assert result.enable_collaboration_mode is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify branding configuration
assert result.branding.application_title == "Test Enterprise"
@ -480,15 +478,11 @@ class TestFeatureService:
assert result.enable_social_oauth_login is False
assert result.enable_collaboration_mode is False
assert result.is_allow_register is True
assert result.is_allow_create_workspace is True
assert result.is_email_setup is True
# Verify marketplace configuration
assert result.enable_marketplace is False
# Verify plugin package size (uses default value from dify_config)
assert result.max_plugin_package_size == 15728640
def test_get_features_billing_disabled(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@ -683,15 +677,11 @@ class TestFeatureService:
assert result.enable_email_password_login is True
assert result.enable_social_oauth_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
assert result.is_email_setup is False
# Verify marketplace configuration
assert result.enable_marketplace is True
# Verify plugin package size (uses default value from dify_config)
assert result.max_plugin_package_size == 15728640
# Verify default license status
assert result.license.status == "none"
assert not hasattr(result.license, "expired_at")
@ -923,7 +913,6 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@ -1672,7 +1661,6 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@ -1851,7 +1839,6 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
assert result.license.status == LicenseStatus.LOST
# Verify mock interactions

View File

@ -41,7 +41,7 @@ class TestWebhookService:
# Mock feature service
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
yield {
"async_service": mock_async_service,

View File

@ -112,8 +112,8 @@ def test_publish_blocks_start_and_trigger_coexistence(
monkeypatch.setattr(
feature_service_module.FeatureService,
"get_system_features",
classmethod(lambda _cls: SimpleNamespace(plugin_manager=SimpleNamespace(enabled=False))),
"is_plugin_manager_enabled",
classmethod(lambda _cls: False),
)
monkeypatch.setattr("services.workflow_service.dify_config", SimpleNamespace(BILLING_ENABLED=False))

View File

@ -110,6 +110,26 @@ def test_generate_specs_writes_unique_operation_ids(tmp_path):
assert len(operation_ids) == len(set(operation_ids))
def test_system_features_specs_exclude_backend_only_fields(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
excluded_fields = {
"is_allow_create_workspace",
"max_plugin_package_size",
"plugin_manager",
}
for spec_name in ("console-openapi.json", "web-openapi.json"):
spec_path = next(path for path in written_paths if path.name == spec_name)
payload = json.loads(spec_path.read_text(encoding="utf-8"))
schemas = payload["components"]["schemas"]
system_features_schema = schemas["SystemFeatureModel"]
assert excluded_fields.isdisjoint(system_features_schema["properties"])
assert "PluginManagerModel" not in schemas
def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path):
module = _load_generate_swagger_specs_module()

View File

@ -442,10 +442,10 @@ class TestEmailCodeLoginApi:
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
def test_email_code_login_creates_workspace_for_user_without_tenant(
self,
mock_get_features,
mock_is_workspace_creation_allowed,
mock_get_tenants,
mock_get_user,
mock_revoke_token,
@ -465,10 +465,7 @@ class TestEmailCodeLoginApi:
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_features = MagicMock()
mock_features.is_allow_create_workspace = True
mock_features.license.workspaces.is_available.return_value = True
mock_get_features.return_value = mock_features
mock_is_workspace_creation_allowed.return_value = True
# Act & Assert - Should not raise WorkspacesLimitExceeded
with app.test_request_context(
@ -486,10 +483,10 @@ class TestEmailCodeLoginApi:
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_license")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
def test_email_code_login_workspace_limit_exceeded(
self,
mock_get_features,
mock_is_workspace_creation_allowed,
mock_get_license,
mock_get_tenants,
mock_get_user,
@ -510,6 +507,7 @@ class TestEmailCodeLoginApi:
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_get_license.return_value.workspaces.is_available.return_value = False
mock_is_workspace_creation_allowed.return_value = True
# Act & Assert
with app.test_request_context(
@ -526,10 +524,10 @@ class TestEmailCodeLoginApi:
@patch("controllers.console.auth.login.AccountService.revoke_email_code_login_token")
@patch("controllers.console.auth.login.AccountService.get_user_through_email")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
def test_email_code_login_workspace_creation_not_allowed(
self,
mock_get_features,
mock_is_workspace_creation_allowed,
mock_get_tenants,
mock_get_user,
mock_revoke_token,
@ -548,9 +546,7 @@ class TestEmailCodeLoginApi:
mock_get_data.return_value = {"email": "test@example.com", "code": "123456"}
mock_get_user.return_value = mock_account
mock_get_tenants.return_value = []
mock_features = MagicMock()
mock_features.is_allow_create_workspace = False
mock_get_features.return_value = mock_features
mock_is_workspace_creation_allowed.return_value = False
# Act & Assert
with app.test_request_context(

View File

@ -344,10 +344,10 @@ class TestLoginApi:
@patch("controllers.console.auth.login.AccountService.authenticate")
@patch("controllers.console.auth.login.TenantService.get_join_tenants")
@patch("controllers.console.auth.login.FeatureService.get_license")
@patch("controllers.console.auth.login.FeatureService.get_system_features")
@patch("controllers.console.auth.login.FeatureService.is_workspace_creation_allowed")
def test_login_fails_when_no_workspace_and_limit_exceeded(
self,
mock_get_features: MagicMock,
mock_is_workspace_creation_allowed: MagicMock,
mock_get_license: MagicMock,
mock_get_tenants: MagicMock,
mock_authenticate: MagicMock,
@ -370,9 +370,7 @@ class TestLoginApi:
mock_authenticate.return_value = mock_account
mock_get_tenants.return_value = [] # No tenants
mock_features = MagicMock()
mock_features.is_allow_create_workspace = True
mock_get_features.return_value = mock_features
mock_is_workspace_creation_allowed.return_value = True
mock_get_license.return_value.workspaces.is_available.return_value = False
# Act & Assert

View File

@ -646,7 +646,7 @@ class TestAccountGeneration:
):
mock_get_account.return_value = mock_account
mock_tenant_service.get_join_tenants.return_value = []
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}):
result, oauth_new_user = _generate_account("github", user_info)

View File

@ -102,6 +102,7 @@ class TestSystemFeatureApi:
result = api.get()
assert result == system_features.model_dump()
assert result["is_allow_register"] is True
assert result["enable_learn_app"] is True
assert result["license"] == {"status": LicenseStatus.NONE}
get_system_features.assert_called_once_with()

View File

@ -7,27 +7,26 @@ from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.web.feature import SystemFeatureApi
from enums.deployment_edition import DeploymentEdition
from services.feature_service import SystemFeatureModel
class TestSystemFeatureApi:
@patch("controllers.web.feature.FeatureService.get_system_features")
def test_returns_system_features(self, mock_features: MagicMock, app: Flask) -> None:
mock_model = MagicMock()
mock_model.model_dump.return_value = {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
mock_features.return_value = mock_model
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
mock_features.return_value = system_features
with app.test_request_context("/system-features"):
result = SystemFeatureApi().get()
assert result == {"sso_enforced_for_signin": False, "webapp_auth": {"enabled": False}}
assert result == system_features.model_dump()
mock_features.assert_called_once()
@patch("controllers.web.feature.FeatureService.get_system_features")
def test_unauthenticated_access(self, mock_features: MagicMock, app: Flask) -> None:
"""SystemFeatureApi is unauthenticated by design — no WebApiResource decorator."""
mock_model = MagicMock()
mock_model.model_dump.return_value = {}
mock_features.return_value = mock_model
mock_features.return_value = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
# Verify it's a bare Resource, not WebApiResource
from flask_restx import Resource

View File

@ -1,4 +1,3 @@
from types import SimpleNamespace
from typing import cast
import pytest
@ -51,8 +50,8 @@ def test_check_credential_policy_compliance_returns_when_feature_disabled(
mocker: MockerFixture,
) -> None:
mocker.patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=False)),
"services.feature_service.FeatureService.is_plugin_manager_enabled",
return_value=False,
)
check_call = mocker.patch(
"services.enterprise.plugin_manager_service.PluginManagerService.check_credential_policy_compliance"
@ -67,8 +66,8 @@ def test_check_credential_policy_compliance_raises_when_credential_missing(
mocker: MockerFixture,
) -> None:
mocker.patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=True)),
"services.feature_service.FeatureService.is_plugin_manager_enabled",
return_value=True,
)
mocker.patch("core.helper.credential_utils.is_credential_exists", return_value=False)
@ -80,8 +79,8 @@ def test_check_credential_policy_compliance_calls_plugin_manager_with_request(
mocker: MockerFixture,
) -> None:
mocker.patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=True)),
"services.feature_service.FeatureService.is_plugin_manager_enabled",
return_value=True,
)
mocker.patch("core.helper.credential_utils.is_credential_exists", return_value=True)
check_call = mocker.patch(
@ -101,8 +100,8 @@ def test_check_credential_policy_compliance_skips_existence_check_when_disabled(
mocker: MockerFixture,
) -> None:
mocker.patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=True)),
"services.feature_service.FeatureService.is_plugin_manager_enabled",
return_value=True,
)
exists_call = mocker.patch("core.helper.credential_utils.is_credential_exists")
check_call = mocker.patch(
@ -124,8 +123,8 @@ def test_check_credential_policy_compliance_returns_when_credential_id_empty(
mocker: MockerFixture,
) -> None:
mocker.patch(
"services.feature_service.FeatureService.get_system_features",
return_value=SimpleNamespace(plugin_manager=SimpleNamespace(enabled=True)),
"services.feature_service.FeatureService.is_plugin_manager_enabled",
return_value=True,
)
exists_call = mocker.patch("core.helper.credential_utils.is_credential_exists")
check_call = mocker.patch(

View File

@ -803,9 +803,7 @@ class TestTenantService:
"""Creating an owner workspace persists both the tenant and owner membership."""
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1023,9 +1021,7 @@ class TestTenantService:
self, sqlite_session: Session, mock_external_service_dependencies
):
mock_account = TestAccountAssociatedDataFactory.create_account_mock(account_id="user-rbac", name="RBAC User")
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1476,9 +1472,7 @@ class TestRegisterService:
"""Test successful account registration."""
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1585,9 +1579,7 @@ class TestRegisterService:
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False)
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1624,9 +1616,7 @@ class TestRegisterService:
monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False)
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1659,9 +1649,7 @@ class TestRegisterService:
"""Test account registration with OAuth integration."""
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1703,9 +1691,7 @@ class TestRegisterService:
"""Test account registration with pending status."""
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
@ -1745,9 +1731,7 @@ class TestRegisterService:
"""Test registration when workspace creation is not allowed."""
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True

View File

@ -0,0 +1,42 @@
import pytest
from services.feature_service import FeatureService
def test_workspace_creation_uses_environment_policy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False)
monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True)
monkeypatch.setattr(
"services.feature_service.EnterpriseService.get_info",
lambda: (_ for _ in ()).throw(AssertionError("enterprise API should not be called")),
)
assert FeatureService.is_workspace_creation_allowed() is True
def test_workspace_creation_uses_enterprise_policy(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
monkeypatch.setattr(
"services.feature_service.EnterpriseService.get_info",
lambda: {"IsAllowCreateWorkspace": False},
)
assert FeatureService.is_workspace_creation_allowed() is False
def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True)
monkeypatch.setattr("services.feature_service.EnterpriseService.get_info", lambda: {})
assert FeatureService.is_workspace_creation_allowed() is True
def test_plugin_manager_is_enabled_only_for_enterprise(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True)
assert FeatureService.is_plugin_manager_enabled() is True
monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False)
assert FeatureService.is_plugin_manager_enabled() is False

View File

@ -28,26 +28,21 @@ export const license = {
}
/**
* Get system-wide feature configuration
* Get the non-sensitive bootstrap snapshot exposed before authentication
*
* Get system-wide feature configuration
* NOTE: This endpoint is unauthenticated by design, as it provides system features
* data required for dashboard initialization.
*
* Authentication would create circular dependency (can't login without dashboard loading).
*
* Only non-sensitive configuration data should be returned by this endpoint. Authenticated
* license detail is served separately by SystemFeatureLicenseApi.
* Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
* Authentication configuration must be available before the authentication flow can be selected.
* Authenticated license detail is served separately by SystemFeatureLicenseApi.
*/
export const get2 = oc
.route({
description:
"Get system-wide feature configuration\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for dashboard initialization.\n\nAuthentication would create circular dependency (can't login without dashboard loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint. Authenticated\nlicense detail is served separately by SystemFeatureLicenseApi.",
'Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.\nAuthentication configuration must be available before the authentication flow can be selected.\nAuthenticated license detail is served separately by SystemFeatureLicenseApi.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getSystemFeatures',
path: '/system-features',
summary: 'Get system-wide feature configuration',
summary: 'Get the non-sensitive bootstrap snapshot exposed before authentication',
tags: ['console'],
})
.output(zGetSystemFeaturesResponse)

View File

@ -19,14 +19,11 @@ export type SystemFeatureModel = {
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_create_workspace: boolean
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel
plugin_manager: PluginManagerModel
rbac_enabled: boolean
sso_enforced_for_signin: boolean
sso_enforced_for_signin_protocol: string
@ -59,10 +56,6 @@ export type PluginInstallationPermissionModel = {
restrict_to_marketplace_only: boolean
}
export type PluginManagerModel = {
enabled: boolean
}
export type WebAppAuthModel = {
allow_email_code_login: boolean
allow_email_password_login: boolean

View File

@ -20,13 +20,6 @@ export const zBrandingModel = z.object({
*/
export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE'])
/**
* PluginManagerModel
*/
export const zPluginManagerModel = z.object({
enabled: z.boolean().default(false),
})
/**
* LicenseLimitationModel
*
@ -109,6 +102,8 @@ export const zWebAppAuthModel = z.object({
/**
* SystemFeatureModel
*
* Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
*/
export const zSystemFeatureModel = z.object({
branding: zBrandingModel.default({
@ -131,17 +126,14 @@ export const zSystemFeatureModel = z.object({
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_create_workspace: z.boolean().default(false),
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({
plugin_installation_scope: 'all',
restrict_to_marketplace_only: false,
}),
plugin_manager: zPluginManagerModel.default({ enabled: false }),
rbac_enabled: z.boolean().default(false),
sso_enforced_for_signin: z.boolean().default(false),
sso_enforced_for_signin_protocol: z.string().default(''),

View File

@ -939,34 +939,23 @@ export const site = {
}
/**
* Get system feature flags and configuration
*
* Get system feature flags and configuration
* Returns the current system feature flags and configuration
* that control various functionalities across the platform.
*
* Returns:
* dict: System feature configuration object
* Get the non-sensitive bootstrap snapshot exposed before authentication
*
* Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
* This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,
* except it is intended for use by the web app, instead of the console dashboard.
*
* NOTE: This endpoint is unauthenticated by design, as it provides system features
* data required for webapp initialization.
*
* Authentication would create circular dependency (can't authenticate without webapp loading).
*
* Only non-sensitive configuration data should be returned by this endpoint.
* Authentication configuration must be available before the authentication flow can be selected.
*/
export const get13 = oc
.route({
description:
"Get system feature flags and configuration\nReturns the current system feature flags and configuration\nthat control various functionalities across the platform.\n\nReturns:\n dict: System feature configuration object\n\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nNOTE: This endpoint is unauthenticated by design, as it provides system features\ndata required for webapp initialization.\n\nAuthentication would create circular dependency (can't authenticate without webapp loading).\n\nOnly non-sensitive configuration data should be returned by this endpoint.",
'Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.\nThis endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,\nexcept it is intended for use by the web app, instead of the console dashboard.\n\nAuthentication configuration must be available before the authentication flow can be selected.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getSystemFeatures',
path: '/system-features',
summary: 'Get system feature flags and configuration',
summary: 'Get the non-sensitive bootstrap snapshot exposed before authentication',
tags: ['web'],
})
.output(zGetSystemFeaturesResponse)

View File

@ -390,10 +390,6 @@ export type PluginInstallationScope =
| 'official_and_specific_partners'
| 'official_only'
export type PluginManagerModel = {
enabled: boolean
}
export type RemoteFileInfo = {
file_length: number
file_type: string
@ -516,14 +512,11 @@ export type SystemFeatureModel = {
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_create_workspace: boolean
is_allow_register: boolean
is_email_setup: boolean
knowledge_fs_enabled: boolean
license: LicenseStatusModel
max_plugin_package_size: number
plugin_installation_permission: PluginInstallationPermissionModel
plugin_manager: PluginManagerModel
rbac_enabled: boolean
sso_enforced_for_signin: boolean
sso_enforced_for_signin_protocol: string

View File

@ -454,13 +454,6 @@ export const zPluginInstallationPermissionModel = z.object({
restrict_to_marketplace_only: z.boolean().default(false),
})
/**
* PluginManagerModel
*/
export const zPluginManagerModel = z.object({
enabled: z.boolean().default(false),
})
/**
* RemoteFileInfo
*/
@ -756,6 +749,8 @@ export const zWebAppAuthModel = z.object({
/**
* SystemFeatureModel
*
* Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
*/
export const zSystemFeatureModel = z.object({
branding: zBrandingModel.default({
@ -778,17 +773,14 @@ export const zSystemFeatureModel = z.object({
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_create_workspace: z.boolean().default(false),
is_allow_register: z.boolean().default(false),
is_email_setup: z.boolean().default(false),
knowledge_fs_enabled: z.boolean().default(false),
license: zLicenseStatusModel.default({ status: 'none' }),
max_plugin_package_size: z.int().default(15728640),
plugin_installation_permission: zPluginInstallationPermissionModel.default({
plugin_installation_scope: 'all',
restrict_to_marketplace_only: false,
}),
plugin_manager: zPluginManagerModel.default({ enabled: false }),
rbac_enabled: z.boolean().default(false),
sso_enforced_for_signin: z.boolean().default(false),
sso_enforced_for_signin_protocol: z.string().default(''),

View File

@ -21,11 +21,9 @@ const baseSystemFeatures = {
enable_email_password_login: true,
enable_social_oauth_login: false,
enable_collaboration_mode: true,
is_allow_create_workspace: false,
is_allow_register: false,
is_email_setup: false,
enable_change_email: true,
max_plugin_package_size: 15728640,
license: {
status: LicenseStatus.NONE,
},
@ -50,9 +48,6 @@ const baseSystemFeatures = {
plugin_installation_scope: InstallationScope.ALL,
restrict_to_marketplace_only: false,
},
plugin_manager: {
enabled: false,
},
rbac_enabled: false,
enable_creators_platform: false,
enable_trial_app: false,
@ -102,10 +97,6 @@ export const createSystemFeaturesFixture = (
...baseSystemFeatures.license,
...overrides.license,
},
plugin_manager: {
...baseSystemFeatures.plugin_manager,
...overrides.plugin_manager,
},
})
export const createSystemFeaturesLicenseFixture = (