Merge remote-tracking branch 'origin/main' into deploy/konwledge

# Conflicts:
#	api/services/feature_service.py
#	api/tests/unit_tests/controllers/console/workspace/test_members.py
#	api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py
#	api/tests/unit_tests/services/test_system_feature_service_knowledge_fs.py
#	pnpm-lock.yaml
This commit is contained in:
Stephen Zhou 2026-09-02 10:31:10 +08:00
commit cd7e1be7cb
No known key found for this signature in database
367 changed files with 9512 additions and 5436 deletions

View File

@ -12,7 +12,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
"version": "24.20.0"
},
"ghcr.io/devcontainers-extra/features/npm-package:1": {
"package": "typescript",
@ -46,4 +46,4 @@
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
}
}

1
.github/CODEOWNERS vendored
View File

@ -28,6 +28,7 @@
# E2E
/e2e/ @lyzno1
/.github/workflows/web-e2e.yml @lyzno1
# Backend (default owner, more specific rules below will override)
/api/ @QuantumGhost

View File

@ -11,6 +11,6 @@ runs:
- name: Setup Vite+
uses: voidzero-dev/setup-vp@1b32467adbe183473499fd9d5d372c3ed9641754 # v1.18.0
with:
node-version-file: .nvmrc
node-version-file: package.json
cache: true
run-install: true

1
.github/labeler.yml vendored
View File

@ -6,7 +6,6 @@ web:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
e2e:
- changed-files:

View File

@ -82,7 +82,6 @@ jobs:
- 'pnpm-workspace.yaml'
- 'lint.config.ts'
- '.npmrc'
- '.nvmrc'
- '.github/workflows/cli-tests.yml'
- '.github/actions/setup-web/**'
web:
@ -91,7 +90,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
- '.github/workflows/main-ci.yml'
- '.github/workflows/web-tests.yml'
- '.github/actions/setup-web/**'
@ -105,7 +103,6 @@ jobs:
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
- 'docker/docker-compose.middleware.yaml'
- 'docker/envs/middleware.env.example'
- '.github/workflows/web-e2e.yml'

View File

@ -39,7 +39,6 @@ jobs:
- 'e2e/tsx-register.js'
- 'package.json'
- 'pnpm-lock.yaml'
- '.nvmrc'
- '.github/workflows/post-merge.yml'
- '.github/workflows/web-e2e.yml'
- '.github/actions/setup-web/**'

View File

@ -114,7 +114,6 @@ jobs:
pnpm-workspace.yaml
knip.config.ts
scripts/check-web-production-unused-after-knip-fix.mjs
.nvmrc
.github/workflows/style.yml
.github/actions/setup-web/**
@ -170,7 +169,6 @@ jobs:
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
lint.config.ts
eslint.config.mjs

View File

@ -31,7 +31,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
node-version-file: package.json
cache: ''
cache-dependency-path: 'pnpm-lock.yaml'

View File

@ -162,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210
uses: anthropics/claude-code-action@833fb0f8c9f6686b33d963a8bae0a94f4936ab2a # v1.0.211
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -12,13 +12,196 @@ permissions:
contents: read
jobs:
test:
core-build:
name: Prepare Core E2E Web Build
if: ${{ !inputs.run-external-runtime }}
runs-on: depot-ubuntu-24.04-4
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Run E2E support unit tests
working-directory: ./e2e
run: vp run test:unit
- name: Build production Web app
working-directory: ./e2e
env:
E2E_FORCE_WEB_BUILD: '1'
run: vp run e2e:web:build
- name: Package Web build
run: >-
tar -cf e2e-web-build.tar -C web
.next/BUILD_ID
.next/e2e-web-build.sha256
.next/standalone
.next/static
- name: Upload Web build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: core-e2e-web-build
path: e2e-web-build.tar
if-no-files-found: error
overwrite: true
retention-days: 7
chromium-full:
name: Chromium Full (${{ matrix.shard }}/3)
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_INIT_PASSWORD: E2eInit12345
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
- name: Install Chromium
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:chromium
- name: Run Chromium full shard
working-directory: ./e2e
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report-chromium-${{ matrix.shard }}
path: e2e/cucumber-report
if-no-files-found: ignore
overwrite: true
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs-chromium-${{ matrix.shard }}
path: e2e/.logs/*.log
if-no-files-found: ignore
include-hidden-files: true
overwrite: true
retention-days: 7
webkit-smoke:
name: WebKit Browser Smoke
if: ${{ !inputs.run-external-runtime }}
needs: core-build
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_BROWSER: webkit
E2E_INIT_PASSWORD: E2eInit12345
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup web dependencies
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
enable-cache: true
python-version: '3.12'
cache-dependency-glob: api/uv.lock
- name: Install API dependencies
run: uv sync --project api --dev
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: core-e2e-web-build
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
- name: Install WebKit
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:webkit
- name: Run WebKit browser smoke
working-directory: ./e2e
run: vp run e2e:full -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report-webkit
path: e2e/cucumber-report
if-no-files-found: ignore
overwrite: true
retention-days: 7
- name: Upload E2E logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs-webkit
path: e2e/.logs/*.log
if-no-files-found: ignore
include-hidden-files: true
overwrite: true
retention-days: 7
external-runtime:
name: Web Full-Stack E2E
if: ${{ inputs.run-external-runtime }}
runs-on: ubuntu-24.04
timeout-minutes: 120
defaults:
run:
shell: bash
steps:
- name: Checkout code
@ -41,82 +224,12 @@ jobs:
- name: Install API dependencies
run: uv sync --project api --dev
- name: Run E2E support unit tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
run: vp run test:unit
- name: Install Playwright browsers for core E2E
if: ${{ !inputs.run-external-runtime }}
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci
- name: Install Chromium for external runtime E2E
if: ${{ inputs.run-external-runtime }}
- name: Install Chromium
timeout-minutes: 15
working-directory: ./e2e
run: vp run e2e:install:ci:chromium
- name: Run isolated source-api and built-web Cucumber E2E tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_FORCE_WEB_BUILD: '1'
E2E_INIT_PASSWORD: E2eInit12345
run: vp run e2e:full
- name: Preserve Chromium E2E report and logs
if: ${{ !cancelled() && !inputs.run-external-runtime }}
run: |
if [[ -d e2e/cucumber-report ]]; then
mv e2e/cucumber-report e2e/cucumber-report-non-external
fi
if [[ -d e2e/.logs ]]; then
mv e2e/.logs e2e/.logs-non-external
fi
- name: Run WebKit keyboard and browser smoke tests
if: ${{ !inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
E2E_ADMIN_NAME: E2E Admin
E2E_ADMIN_PASSWORD: E2eAdmin12345
E2E_BROWSER: webkit
E2E_INIT_PASSWORD: E2eInit12345
run: |
teardown_webkit_smoke() {
local run_status=$?
trap - EXIT
if ! vp run e2e:middleware:down; then
echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly."
if [[ "$run_status" -eq 0 ]]; then
run_status=1
fi
fi
exit "$run_status"
}
trap teardown_webkit_smoke EXIT
vp run e2e:middleware:up
vp run e2e -- --tags '@browser-smoke'
- name: Preserve WebKit E2E report and logs
if: ${{ !cancelled() && !inputs.run-external-runtime }}
run: |
if [[ -d e2e/cucumber-report ]]; then
mv e2e/cucumber-report e2e/cucumber-report-webkit
fi
if [[ -d e2e/.logs ]]; then
mv e2e/.logs e2e/.logs-webkit
fi
- name: Run prepared and external runtime E2E tests
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: e2e-admin@example.com
@ -149,10 +262,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cucumber-report
path: |
e2e/cucumber-report
e2e/cucumber-report-non-external
e2e/cucumber-report-webkit
path: e2e/cucumber-report
overwrite: true
retention-days: 7
- name: Upload E2E logs
@ -160,18 +271,17 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-logs
path: |
e2e/.logs/*.log
e2e/.logs-non-external/*.log
e2e/.logs-webkit/*.log
path: e2e/.logs/*.log
include-hidden-files: true
overwrite: true
retention-days: 7
- name: Upload E2E seed report
if: ${{ !cancelled() && inputs.run-external-runtime }}
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-seed-report
path: e2e/seed-report
if-no-files-found: ignore
overwrite: true
retention-days: 7

1
.nvmrc
View File

@ -1 +0,0 @@
22

View File

@ -7,3 +7,9 @@ Dify is an open-source platform for building LLM applications, agentic workflows
- Run backend commands through `uv run --project api <command>`.
- Backend integration tests are CI-only and are not expected to run locally.
- Keep `docker/.env.example` limited to variables required for a default Docker Compose deployment to start. Put optional and provider-specific settings in the matching `docker/envs/*.env.example` file; `docker/.env` overrides those service-specific env files.
## Frontend Workflow
- For truncated text disclosure and native `title` decisions, follow [Truncated Text Disclosure].
[Truncated Text Disclosure]: web/docs/truncated-text-disclosure.md

View File

@ -65,7 +65,7 @@ ignore_imports =
core.app.workflow.layers.persistence -> services.workflow.inspector_events
core.datasource.datasource_manager -> services.datasource_provider_service
core.helper.credential_utils -> services.enterprise.plugin_manager_service
core.helper.credential_utils -> services.feature_service
core.helper.credential_utils -> services.system_feature_service
core.indexing_runner -> services.vector_space_admission_service
core.mcp.auth_client -> services
core.provider_manager -> services.credential_permission_service
@ -99,9 +99,9 @@ ignore_imports =
core.workflow.nodes.agent_v2.workspace_retirement_layer -> tasks.collect_agent_resources_task
libs.device_flow_security -> controllers.openapi._models
libs.device_flow_security -> services.entities.feature_entities
libs.device_flow_security -> services.feature_service
libs.device_flow_security -> services.system_feature_service
libs.email_i18n -> services.entities.feature_entities
libs.email_i18n -> services.feature_service
libs.email_i18n -> services.system_feature_service
libs.external_api -> core
libs.external_api -> core.errors.error
libs.external_api -> extensions.ext_logging
@ -117,7 +117,6 @@ ignore_imports =
libs.oauth_bearer -> models
libs.rsa -> extensions.ext_storage
libs.workspace_permission -> services.enterprise.enterprise_service
libs.workspace_permission -> services.feature_service
services.account_service -> controllers
services.account_service -> controllers.console.error
services.app_generate_service -> controllers.console.app.workflow
@ -387,6 +386,37 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:inner-mail-service-boundary]
name = Inner mail application service is framework and implementation neutral
type = forbidden
source_modules =
services.inner_mail_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
tasks
werkzeug
[importlinter:contract:web-passport-service-boundary]
name = Web passport application service is framework and persistence neutral
type = forbidden
source_modules =
services.web_passport_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-activation-service-boundary]
name = Account activation application service is framework and persistence neutral
type = forbidden

View File

@ -53,15 +53,14 @@ WORKDIR /app/api
# Create non-root user
ARG dify_uid=1001
ARG NODE_MAJOR=22
ARG NODE_PACKAGE_VERSION=22.21.0-1nodesource1
ARG NODE_PACKAGE_VERSION=24.20.0-1nodesource1
ARG NODESOURCE_KEY_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4
RUN groupadd -r -g ${dify_uid} dify && \
useradd -r -u ${dify_uid} -g ${dify_uid} -s /bin/bash dify && \
chown -R dify:dify /app
RUN \
apt-get update \
RUN NODE_MAJOR="${NODE_PACKAGE_VERSION%%.*}" \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \

View File

@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -527,8 +527,7 @@ class AgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_EDIT)
@with_current_user_id
@with_current_tenant_id
@with_session

View File

@ -7,7 +7,6 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
@ -33,6 +32,7 @@ from controllers.console.app.app import (
from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.app.wraps import agent_manage_required_for_agent_app
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -79,11 +79,9 @@ from services.agent.observability_service import (
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
from services.feature_service import FeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
from services.system_feature_service import SystemFeatureService
AgentPublicationStatus = Literal["published", "drafts"]
@ -390,7 +388,7 @@ def _serialize_agent_app_detail(
"""
app_model = AppService().get_app(app_model, session=session)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
@ -687,15 +685,6 @@ class AgentAppListApi(Resource):
)
app = AppService().create_app(current_tenant_id, params, current_user, session=session)
if dify_config.RBAC_ENABLED:
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
current_tenant_id,
current_user.id,
str(app.id),
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
)
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id)
return _serialize_agent_app_detail(session, app, current_user=current_user), 201
@ -995,8 +984,7 @@ class AgentApiStatusApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
@with_session
@model_validate(AgentApiStatusPayload)
@ -1014,10 +1002,9 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
@ -1027,8 +1014,7 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
@ -1047,8 +1033,7 @@ class AgentApiKeyApi(BaseApiKeyResource):
@console_ns.response(204, "Agent service API key deleted")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_session
def delete(
self,

View File

@ -23,7 +23,7 @@ from controllers.common.schema import (
)
from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -153,7 +153,7 @@ class AgentAppSandboxInfoResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -183,7 +183,7 @@ class AgentAppSandboxListResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -214,7 +214,7 @@ class AgentAppSandboxReadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
@ -245,7 +245,7 @@ class AgentAppSandboxDownloadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_tenant_id
@with_current_user
@model_validate(AgentSandboxDownloadPayload)

View File

@ -77,7 +77,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
WeightVectorSetting,
)
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
@ -516,7 +516,7 @@ class AppImportResponse(ResponseModel):
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
@ -877,7 +877,7 @@ class AppApi(Resource):
app_model = app_service.get_app(app_model, session=session)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
@ -1002,7 +1002,7 @@ class AppCopyApi(Resource):
session.commit()
# Inherit web app permission from original app
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
try:
# Get the original app's access mode
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_model.id)
@ -1173,8 +1173,7 @@ class AppSiteStatus(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@agent_manage_required_for_agent_app
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_RELEASE_AND_VERSION)
@with_session
@get_app_model(mode=None)
@model_validate(AppSiteStatusPayload)

View File

@ -31,7 +31,7 @@ from services.app_dsl_service import (
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from .. import console_ns
from .permission_keys import get_app_permission_keys
@ -127,7 +127,7 @@ class AppImportApi(Resource):
result.app_id,
)
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
if result.app_id and SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result

View File

@ -24,7 +24,7 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import get_app_model, with_session
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model, with_session
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
@ -263,7 +263,7 @@ class AgentChatMessageApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session
@ -292,7 +292,7 @@ class AgentBuildChatFinalizeApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session

View File

@ -21,7 +21,7 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.app.wraps import get_app_model
from controllers.console.app.wraps import agent_manage_required_for_agent_app, get_app_model
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
from controllers.console.wraps import (
RBACPermission,
@ -174,7 +174,7 @@ class AgentChatMessageListApi(Resource):
@account_initialization_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@agent_manage_required_for_agent_app(scene=RBACPermission.APP_VIEW_LAYOUT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)

View File

@ -8,7 +8,6 @@ from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.error import AppNotFoundError
from controllers.console.app.wraps import agent_manage_required_for_agent_app
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
@ -108,8 +107,8 @@ class AppSite(Resource):
allowed_roles=_APP_SITE_EDIT_ROLES,
rbac_resource_scope=RBACResourceScope.APP,
rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION,
agent_manage_fallback=True,
)
@agent_manage_required_for_agent_app
@model_validate(AppSiteUpdatePayload)
def post(
self,
@ -139,8 +138,8 @@ class AppSiteAccessTokenReset(Resource):
allowed_roles=_APP_SITE_TOKEN_RESET_ROLES,
rbac_resource_scope=RBACResourceScope.APP,
rbac_permission=RBACPermission.APP_RELEASE_AND_VERSION,
agent_manage_fallback=True,
)
@agent_manage_required_for_agent_app
def post(self, request_context: RequestContext, app_id: UUID):
try:
site = application_services().app_sites.reset_access_token(request_context, str(app_id))

View File

@ -12,20 +12,22 @@ from typing import cast, overload
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.session import with_session
from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access
from controllers.common.wraps import RBACPermission, RBACResourceScope, _extract_resource_id, enforce_rbac_access
from controllers.console.app.error import AppNotFoundError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
from models.agent import Agent, AgentScope
from services.app_service import AppService
__all__ = [
"agent_manage_required_for_agent_app",
"enforce_agent_manage_or_app_scene",
"get_app_model",
"get_previewable_app_model",
"with_session",
@ -57,43 +59,116 @@ def _load_previewable_app_model(session: Session, app_id: str) -> App | None:
return AppService.get_normal_app_by_id(app_id, session)
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Gate generic app management routes that target an Agent App.
def _agent_app_binding(app_id: str) -> Agent | None:
app_model = _load_app_model_from_scoped_session(app_id)
if app_model is None:
return None
return app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
A hidden workflow-only backing App only reuses the App runtime and is not
part of the general app management plane, so generic routes reject it
outright. Managing a roster Agent App mutates the roster Agent behind it
(rename/icon sync, archive, API enablement), so it additionally requires
workspace ``agent.manage`` on top of the route's existing App permission
checks when RBAC is enabled. A no-op for non-agent Apps. Must be placed
above ``get_app_model`` so the ``app_id`` path parameter is still present.
"""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
if raw_app_id is not None:
app_model = _load_app_model_from_scoped_session(str(raw_app_id))
binding = (
app_model.agent_app_binding_with_session(session=db.session(), include_archived=True)
if app_model is not None
else None
def _reject_hidden_agent_backing_app(path_args: dict[str, object]) -> None:
raw_app_id = path_args.get("app_id") or path_args.get("resource_id")
if raw_app_id is None:
return
binding = _agent_app_binding(str(raw_app_id))
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
def enforce_agent_manage_or_app_scene(
*,
tenant_id: str,
account_id: str,
scene: RBACPermission,
path_args: dict[str, object],
) -> None:
# Must run before the RBAC_ENABLED check below: a hidden workflow-only
# backing App has to stay unreachable regardless of RBAC_ENABLED.
_reject_hidden_agent_backing_app(path_args)
if not dify_config.RBAC_ENABLED:
return
binding = _agent_app_binding(_extract_resource_id(RBACResourceScope.APP, tenant_id, path_args))
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
try:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account_id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
if dify_config.RBAC_ENABLED:
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
return view(*args, **kwargs)
return
except Forbidden:
pass # not an agent.manage holder — fall through to the normal scene check
return decorated
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account_id,
resource_type=RBACResourceScope.APP,
scene=scene,
path_args=path_args,
)
@overload
def agent_manage_required_for_agent_app[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
@overload
def agent_manage_required_for_agent_app[**P, R](
view: None = None, *, scene: RBACPermission | None = None
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def agent_manage_required_for_agent_app[**P, R](
view: Callable[P, R] | None = None, *, scene: RBACPermission | None = None
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
# Must sit above get_app_model in the decorator stack — get_app_model
# deletes app_id from kwargs, and this decorator needs it.
# TODO: this is a workaround, remove this after ACL for agent app is available
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if scene is not None:
if not dify_config.RBAC_ENABLED:
_reject_hidden_agent_backing_app(kwargs)
return view_func(*args, **kwargs)
current_user, current_tenant_id = current_account_with_tenant()
enforce_agent_manage_or_app_scene(
tenant_id=current_tenant_id,
account_id=current_user.id,
scene=scene,
path_args=kwargs,
)
return view_func(*args, **kwargs)
raw_app_id = kwargs.get("app_id") or kwargs.get("resource_id")
if raw_app_id is not None:
binding = _agent_app_binding(str(raw_app_id))
if binding is not None:
if binding.scope == AgentScope.WORKFLOW_ONLY:
raise AppNotFoundError()
if dify_config.RBAC_ENABLED:
current_user, current_tenant_id = current_account_with_tenant()
enforce_rbac_access(
tenant_id=current_tenant_id,
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.AGENT_MANAGE,
resource_required=False,
)
return view_func(*args, **kwargs)
return decorated
if view is None:
return decorator
return decorator(view)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:

View File

@ -25,7 +25,7 @@ from services.entities.auth_entities import (
ForgotPasswordResetPayload,
ForgotPasswordSendPayload,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class ForgotPasswordEmailResponse(BaseModel):
@ -87,7 +87,7 @@ class ForgotPasswordSendEmailApi(Resource):
account=account,
email=normalized_email,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
is_allow_register=SystemFeatureService.is_registration_allowed(),
)
return {"result": "success", "data": token}
@ -198,6 +198,6 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account, session=db.session())
and FeatureService.is_workspace_creation_allowed()
and SystemFeatureService.is_workspace_creation_allowed()
):
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -81,7 +81,7 @@ from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.turnstile_service import (
EMAIL_CODE_VERIFY_ACTION,
TurnstileChallengeRejectedError,
@ -201,8 +201,8 @@ class LoginApi(Resource):
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
FeatureService.is_workspace_creation_allowed()
and not FeatureService.get_license().workspaces.is_available()
SystemFeatureService.is_workspace_creation_allowed()
and not SystemFeatureService.get_license().workspaces.is_available()
):
raise WorkspacesLimitExceeded()
else:
@ -272,7 +272,7 @@ class ResetPasswordSendEmailApi(Resource):
email=normalized_email,
account=account,
language=language,
is_allow_register=FeatureService.get_system_features().is_allow_register,
is_allow_register=SystemFeatureService.is_registration_allowed(),
)
return SimpleResultDataResponse(result="success", data=token).model_dump(mode="json")
@ -313,7 +313,7 @@ class EmailCodeLoginSendEmailApi(Resource):
raise AccountInFreezeError() from exc
if account is None:
if FeatureService.get_system_features().is_allow_register:
if SystemFeatureService.is_registration_allowed():
token = AccountService.send_email_code_login_email(email=normalized_email, language=language)
else:
raise AccountNotFound()
@ -398,10 +398,10 @@ class EmailCodeLoginApi(Resource):
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
workspaces = FeatureService.get_license().workspaces
workspaces = SystemFeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.is_workspace_creation_allowed():
if not SystemFeatureService.is_workspace_creation_allowed():
raise NotAllowedCreateWorkspace()
else:
TenantService.create_owner_tenant(account, session=db.session())

View File

@ -36,7 +36,7 @@ from services.errors.account import (
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkSpaceNotFoundError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from .. import console_ns
@ -310,7 +310,7 @@ def _generate_account(
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
if not FeatureService.is_workspace_creation_allowed():
if not SystemFeatureService.is_workspace_creation_allowed():
raise WorkSpaceNotAllowedCreateError()
else:
TenantService.create_owner_tenant(account, session=db.session())
@ -318,7 +318,7 @@ def _generate_account(
if not account:
normalized_email = user_info.email.lower()
oauth_new_user = True
if not FeatureService.get_system_features().is_allow_register:
if not SystemFeatureService.is_registration_allowed():
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
freeze_type = BillingService.get_email_freeze_type(normalized_email)
if freeze_type:

View File

@ -17,7 +17,7 @@ from controllers.console.wraps import (
with_current_user,
)
from extensions.ext_database import db
from fields.dataset_fields import DatasetDetailResponse
from fields.dataset_fields import DatasetDetailResponse, dataset_detail_response_source
from libs.helper import dump_response
from libs.login import login_required
from models import Account
@ -116,6 +116,7 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
# The role of the current user in the ta table must be admin, owner, or editor, or dataset_operator
if not current_user.is_dataset_editor:
raise Forbidden()
session = db.session()
dataset = DatasetService.create_empty_rag_pipeline_dataset(
tenant_id=current_tenant_id,
rag_pipeline_dataset_create_entity=RagPipelineDatasetCreateEntity(
@ -129,6 +130,6 @@ class CreateEmptyRagPipelineDatasetApi(Resource):
permission=DatasetPermissionEnum.ONLY_ME,
partial_member_list=None,
),
session=db.session(),
session=session,
)
return dump_response(DatasetDetailResponse, dataset), 201
return dump_response(DatasetDetailResponse, dataset_detail_response_source(dataset, session=session)), 201

View File

@ -18,7 +18,7 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@ -55,8 +55,7 @@ def user_allowed_to_access_app[**P, R](view: Callable[Concatenate[InstalledApp,
@wraps(view)
def decorated(installed_app: InstalledApp, *args: P.args, **kwargs: P.kwargs):
current_user, _ = current_account_with_tenant()
feature = FeatureService.get_system_features()
if feature.webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
app_id = installed_app.app_id
res = EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(
user_id=str(current_user.id),

View File

@ -125,7 +125,7 @@ class SystemFeatureApi(Resource):
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
"""
return dump_response(SystemFeatureModel, application_services().feature_queries.get_system_features())
return dump_response(SystemFeatureModel, application_services().feature_queries.get_public_system_features())
@console_ns.route("/system-features/license")

View File

@ -9,6 +9,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.console.app.wraps import enforce_agent_manage_or_app_scene
from controllers.console.wraps import (
account_initialization_required,
enable_change_email,
@ -22,7 +23,7 @@ from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
from machinery.errors import AdmissionConfigurationError
from models.account import TenantAccountRole
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
def console_email_registration_admission[T, **P, R](
@ -32,8 +33,10 @@ def console_email_registration_admission[T, **P, R](
@wraps(view)
def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
features = FeatureService.get_system_features()
if not features.enable_email_password_login or not features.is_allow_register:
if (
not SystemFeatureService.is_email_password_login_enabled()
or not SystemFeatureService.is_registration_allowed()
):
abort(403)
return view(self, *args, **kwargs)
@ -50,6 +53,7 @@ def console_account_admission[T, **P, R](
rbac_resource_scope: RBACResourceScope | None = None,
rbac_permission: RBACPermission | None = None,
rbac_resource_required: bool = True,
agent_manage_fallback: bool = False,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
@ -64,6 +68,10 @@ def console_account_admission[T, **P, R](
if (rbac_resource_scope is None) != (rbac_permission is None):
raise AdmissionConfigurationError("RBAC resource scope and permission must be configured together")
if agent_manage_fallback and rbac_resource_scope != RBACResourceScope.APP:
raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_scope=RBACResourceScope.APP")
if agent_manage_fallback and not rbac_resource_required:
raise AdmissionConfigurationError("agent_manage_fallback requires rbac_resource_required=True")
def decorator(
view: Callable[Concatenate[T, RequestContext, P], R],
@ -76,14 +84,22 @@ def console_account_admission[T, **P, R](
if allowed_roles is not None and not dify_config.RBAC_ENABLED and account.role not in allowed_roles:
raise Forbidden()
if rbac_resource_scope is not None and rbac_permission is not None:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account.id,
resource_type=rbac_resource_scope,
scene=rbac_permission,
resource_required=rbac_resource_required,
path_args=kwargs,
)
if agent_manage_fallback:
enforce_agent_manage_or_app_scene(
tenant_id=tenant_id,
account_id=account.id,
scene=rbac_permission,
path_args=kwargs,
)
else:
enforce_rbac_access(
tenant_id=tenant_id,
account_id=account.id,
resource_type=rbac_resource_scope,
scene=rbac_permission,
resource_required=rbac_resource_required,
path_args=kwargs,
)
request_context = RequestContext(
account_id=account.id,
active_workspace_id=tenant_id,

View File

@ -45,6 +45,7 @@ from models.account import Account, TenantAccountJoin, TenantAccountRole
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import AccountAlreadyInTenantError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class MemberInvitePayload(BaseModel):
@ -185,7 +186,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
if new_account_count > 0:
seats = FeatureService.get_license().seats
seats = SystemFeatureService.get_license().seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return

View File

@ -1,7 +1,7 @@
import io
from typing import Any, Literal
from flask import request, send_file
from flask import send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
@ -15,6 +15,7 @@ from controllers.console.wraps import (
RBACResourceScope,
account_initialization_required,
is_admin_or_owner_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
@ -154,10 +155,8 @@ class ModelProviderListApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
payload = request.args.to_dict(flat=True)
args = ParserModelList.model_validate(payload)
@model_validate(ParserModelList)
def get(self, args: ParserModelList, tenant_id: str):
model_provider_service = ModelProviderService()
provider_list = model_provider_service.get_provider_list(tenant_id=tenant_id, model_type=args.model_type)
@ -212,12 +211,10 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
# if credential_id is not provided, return current used credential
payload = request.args.to_dict(flat=True)
args = ParserCredentialId.model_validate(payload)
@model_validate(ParserCredentialId)
def get(self, args: ParserCredentialId, tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
# if credential_id is not provided, return current used credential
credentials = model_provider_service.get_provider_credential(
tenant_id=tenant_id, provider=provider, credential_id=args.credential_id
)
@ -232,10 +229,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialCreate.model_validate(payload)
@model_validate(ParserCredentialCreate)
def post(self, args: ParserCredentialCreate, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
try:
@ -258,10 +253,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def put(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialUpdate.model_validate(payload)
@model_validate(ParserCredentialUpdate)
def put(self, args: ParserCredentialUpdate, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
try:
@ -285,10 +278,8 @@ class ModelProviderCredentialApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialDelete.model_validate(payload)
@model_validate(ParserCredentialDelete)
def delete(self, args: ParserCredentialDelete, current_tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
model_provider_service.remove_provider_credential(
tenant_id=current_tenant_id, provider=provider, credential_id=args.credential_id
@ -307,10 +298,8 @@ class ModelProviderCredentialSwitchApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialSwitch.model_validate(payload)
@model_validate(ParserCredentialSwitch)
def post(self, args: ParserCredentialSwitch, current_tenant_id: str, provider: str):
service = ModelProviderService()
service.switch_active_provider_credential(
tenant_id=current_tenant_id,
@ -332,10 +321,8 @@ class ModelProviderValidateApi(Resource):
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserCredentialValidate.model_validate(payload)
@model_validate(ParserCredentialValidate)
def post(self, args: ParserCredentialValidate, current_tenant_id: str, provider: str):
tenant_id = current_tenant_id
model_provider_service = ModelProviderService()
@ -388,10 +375,8 @@ class PreferredProviderTypeUpdateApi(Resource):
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
payload = console_ns.payload or {}
args = ParserPreferredProviderType.model_validate(payload)
@model_validate(ParserPreferredProviderType)
def post(self, args: ParserPreferredProviderType, tenant_id: str, provider: str):
model_provider_service = ModelProviderService()
model_provider_service.switch_preferred_provider(
tenant_id=tenant_id, provider=provider, preferred_provider_type=args.preferred_provider_type

View File

@ -19,6 +19,7 @@ from controllers.common.wraps import (
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
@ -31,6 +32,7 @@ from services.billing_service import BillingService
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.operation_service import OperationService, UtmInfo
from services.system_feature_service import SystemFeatureService
from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout
@ -183,7 +185,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(current_tenant_id)
vector_space = application_services().feature_queries.get_workspace_vector_space(current_tenant_id)
if 0 < vector_space.limit <= vector_space.size:
abort(
403,
@ -330,8 +332,11 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
settings = FeatureService.get_system_features()
if settings.license.status in [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]:
if SystemFeatureService.get_license_status() in [
LicenseStatus.INACTIVE,
LicenseStatus.EXPIRED,
LicenseStatus.LOST,
]:
raise UnauthorizedAndForceLogout("Your license is invalid. Please contact your administrator.")
return view(*args, **kwargs)
@ -342,8 +347,7 @@ def enterprise_license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features()
if features.enable_email_password_login:
if SystemFeatureService.is_email_password_login_enabled():
return view(*args, **kwargs)
# otherwise, return 403
@ -355,8 +359,7 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]
def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
features = FeatureService.get_system_features()
if features.enable_change_email:
if SystemFeatureService.is_change_email_enabled():
return view(*args, **kwargs)
# otherwise, return 403
@ -372,7 +375,11 @@ def is_allow_transfer_owner[**P, R](view: Callable[P, R]) -> Callable[P, R]:
_, current_tenant_id = current_account_with_tenant()
# Check both billing/plan level and workspace policy level permissions
check_workspace_owner_transfer_permission(current_tenant_id)
features = application_services().feature_queries.get_workspace_features(current_tenant_id)
check_workspace_owner_transfer_permission(
current_tenant_id,
owner_transfer_allowed=features.is_allow_transfer_workspace,
)
return view(*args, **kwargs)
return decorated

View File

@ -6,8 +6,9 @@ from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import billing_inner_api_only, enterprise_inner_api_only
from tasks.mail_inner_task import send_inner_email_task
from controllers.inner_api.wraps import inner_api_only
from extensions.ext_application_services import application_services
from services.entities.mail_entities import InnerMailMessage
class InnerMailPayload(BaseModel):
@ -28,25 +29,27 @@ class BaseMail(Resource):
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
def post(self):
args = InnerMailPayload.model_validate(inner_api_ns.payload or {})
send_inner_email_task.delay(
to=args.to,
subject=args.subject,
body=args.body,
substitutions=args.substitutions, # type: ignore
application_services().inner_mail.send(
InnerMailMessage(
recipients=tuple(args.to),
subject=args.subject,
body=args.body,
substitutions=args.substitutions,
)
)
return {"message": "success"}, 200
@inner_api_ns.route("/enterprise/mail")
class EnterpriseMail(BaseMail):
method_decorators = [setup_required, enterprise_inner_api_only]
@inner_api_ns.doc("send_enterprise_mail")
@inner_api_ns.doc(description="Send internal email for enterprise features")
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
@inner_api_ns.doc(
responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"}
)
@inner_api_only
@setup_required
def post(self):
"""Send internal email for enterprise features.
@ -61,14 +64,14 @@ class EnterpriseMail(BaseMail):
@inner_api_ns.route("/billing/mail")
class BillingMail(BaseMail):
method_decorators = [setup_required, billing_inner_api_only]
@inner_api_ns.doc("send_billing_mail")
@inner_api_ns.doc(description="Send internal email for billing notifications")
@inner_api_ns.expect(inner_api_ns.models[InnerMailPayload.__name__])
@inner_api_ns.doc(
responses={200: "Email sent successfully", 401: "Unauthorized - invalid API key", 404: "Service not available"}
)
@inner_api_only
@setup_required
def post(self):
"""Send internal email for billing notifications.

View File

@ -35,10 +35,6 @@ def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return decorated
def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)
def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)

View File

@ -7,7 +7,7 @@ from controllers.openapi.auth.data import AuthData, RequestContext
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from services.enterprise.enterprise_service import WebAppAccessMode
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
CondFn = Callable[[RequestContext, AuthData | None], bool]
@ -50,7 +50,7 @@ EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == Deploy
EDITION_ENTERPRISE = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE)
EDITION_CLOUD = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD)
WEBAPP_AUTH_ENABLED = config_cond(lambda: FeatureService.get_system_features().webapp_auth.enabled)
WEBAPP_AUTH_ENABLED = config_cond(lambda: SystemFeatureService.is_webapp_auth_enabled())
WEBAPP_RUN_SCOPED = request_cond(lambda ctx: ctx.scope == Scope.APPS_RUN)

View File

@ -37,7 +37,7 @@ from libs.oauth_bearer import (
)
from models.account import TenantAccountRole
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class AuthPipeline:
@ -265,8 +265,11 @@ def _subject_type_str(identity: Any) -> str | None:
def _check_license() -> None:
settings = FeatureService.get_system_features()
if settings.license.status in {LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST}:
if SystemFeatureService.get_license_status() in {
LicenseStatus.INACTIVE,
LicenseStatus.EXPIRED,
LicenseStatus.LOST,
}:
raise Forbidden("license_invalid")

View File

@ -1,7 +1,6 @@
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from flask_restx.api import HTTPStatus
from pydantic import BaseModel, Field, TypeAdapter
@ -207,9 +206,9 @@ class AnnotationListApi(Resource):
)
@validate_app_token
@with_session(write=False)
def get(self, session: Session, app_model: App):
@model_validate(AnnotationListQuery)
def get(self, query: AnnotationListQuery, session: Session, app_model: App):
"""List annotations for the application."""
query = AnnotationListQuery.model_validate(request.args.to_dict(flat=True))
annotation_list, total = AppAnnotationService.get_annotation_list_by_app_id(
app_model.id, query.page, query.limit, query.keyword, session

View File

@ -2,7 +2,6 @@ from datetime import datetime
from typing import Annotated, Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema, field_validator
from sqlalchemy.orm import sessionmaker
@ -185,7 +184,8 @@ class ConversationApi(Resource):
service_api_ns.models[ConversationInfiniteScrollPagination.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser):
@model_validate(ConversationListQuery)
def get(self, query_args: ConversationListQuery, app_model: App, end_user: EndUser):
"""List all conversations for the current user.
Supports pagination using last_id and limit parameters.
@ -194,7 +194,6 @@ class ConversationApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
query_args = ConversationListQuery.model_validate(request.args.to_dict())
last_id = query_args.last_id or None
try:
@ -343,7 +342,8 @@ class ConversationVariablesApi(Resource):
service_api_ns.models[ConversationVariableInfiniteScrollPaginationResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, conversation_id: UUID):
@model_validate(ConversationVariablesQuery)
def get(self, query_args: ConversationVariablesQuery, app_model: App, end_user: EndUser, conversation_id: UUID):
"""List all variables for a conversation.
Conversational variables are only available for chat applications.
@ -355,7 +355,6 @@ class ConversationVariablesApi(Resource):
conversation_id_str = str(conversation_id)
query_args = ConversationVariablesQuery.model_validate(request.args.to_dict())
last_id = query_args.last_id or None
try:

View File

@ -2,7 +2,7 @@ import logging
from urllib.parse import quote
from uuid import UUID
from flask import Response, request
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
@ -10,6 +10,7 @@ from sqlalchemy import select
from controllers.common.fields import BinaryFileResponse
from controllers.common.file_response import enforce_download_for_html
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_model
from controllers.console.wraps import model_validate
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import (
FileAccessDeniedError,
@ -86,7 +87,8 @@ class FilePreviewApi(Resource):
)
@service_api_ns.response(200, "File retrieved successfully")
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, file_id: UUID):
@model_validate(FilePreviewQuery)
def get(self, args: FilePreviewQuery, app_model: App, end_user: EndUser, file_id: UUID):
"""
Preview/Download a file that was uploaded via Service API.
@ -95,9 +97,6 @@ class FilePreviewApi(Resource):
"""
file_id_str = str(file_id)
# Parse query parameters
args = FilePreviewQuery.model_validate(request.args.to_dict())
# Validate file ownership and get file objects
_, upload_file = self._validate_file_ownership(file_id_str, app_model.id)

View File

@ -2,7 +2,6 @@ import logging
from typing import Annotated
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
@ -102,7 +101,8 @@ class MessageListApi(Resource):
service_api_ns.models[MessageInfiniteScrollPagination.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser):
@model_validate(MessageListQuery)
def get(self, query_args: MessageListQuery, app_model: App, end_user: EndUser):
"""List messages in a conversation.
Retrieves messages with pagination support using first_id.
@ -111,7 +111,6 @@ class MessageListApi(Resource):
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
query_args = MessageListQuery.model_validate(request.args.to_dict())
conversation_id = query_args.conversation_id
first_id = query_args.first_id or None
@ -212,12 +211,12 @@ class AppGetFeedbacksApi(Resource):
service_api_ns.models[AppFeedbackListResponse.__name__],
)
@validate_app_token
def get(self, app_model: App):
@model_validate(FeedbackListQuery)
def get(self, query_args: FeedbackListQuery, app_model: App):
"""Get all feedbacks for the application.
Returns paginated list of all feedback submitted for messages in this app.
"""
query_args = FeedbackListQuery.model_validate(request.args.to_dict())
feedbacks = MessageService.get_all_messages_feedbacks(
app_model, page=query_args.page, limit=query_args.limit, session=db.session()
)

View File

@ -23,6 +23,7 @@ from controllers.service_api.schema import (
USER_REQUIRED_ATTR,
)
from enums import CloudPlan, DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import current_user
@ -196,7 +197,7 @@ def cloud_edition_billing_resource_check[**P, R](
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return view(*args, **kwargs)
vector_space = FeatureService.get_vector_space(api_token.tenant_id)
vector_space = application_services().feature_queries.get_workspace_vector_space(api_token.tenant_id)
if vector_space.usage_unknown:
features = FeatureService.get_features(api_token.tenant_id, exclude_vector_space=True)
if features.billing.enabled and features.billing.subscription.plan == CloudPlan.SANDBOX:

View File

@ -32,5 +32,5 @@ class SystemFeatureApi(Resource):
"""
return dump_response(
SystemFeatureModel,
application_services().feature_queries.get_system_features(),
application_services().feature_queries.get_public_system_features(),
)

View File

@ -1,27 +1,22 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from werkzeug.exceptions import NotFound, Unauthorized
from configs import dify_config
from constants import HEADER_NAME_APP_CODE
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.web import web_ns
from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.passport import PassportService
from libs.token import extract_webapp_access_token
from models.enums import EndUserType
from models.model import App, EndUser, Site
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
from services.entities.passport_entities import WebPassportRequest
from services.web_passport_service import (
WebPassportAuthenticationRequiredError,
WebPassportNotFoundError,
WebPassportUnauthorizedError,
)
class PassportQuery(BaseModel):
@ -40,7 +35,7 @@ register_response_schema_models(web_ns, PassportAccessTokenResponse)
@web_ns.route("/passport")
class PassportResource(Resource):
"""Base resource for passport."""
"""Issue an authentication passport for a deployed web application."""
@web_ns.doc("get_passport")
@web_ns.doc(description="Get authentication passport for web application access")
@ -54,207 +49,23 @@ class PassportResource(Resource):
)
@web_ns.response(200, "Passport retrieved successfully", web_ns.models[PassportAccessTokenResponse.__name__])
def get(self):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
user_id = request.args.get("user_id")
access_token = extract_webapp_access_token(request)
if app_code is None:
raise Unauthorized("X-App-Code header is missing.")
if system_features.webapp_auth.enabled:
enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token)
app_auth_type = WebAppAuthService.get_app_auth_type(app_code=app_code, session=db.session())
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return dump_response(
PassportAccessTokenResponse,
exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
),
)
# get site from db and check if it is normal
site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal"))
if not site:
raise NotFound()
# get app from db and check if it is normal and enable_site
app_model = db.session.scalar(select(App).where(App.id == site.app_id))
if not app_model or app_model.status != "normal" or not app_model.enable_site:
raise NotFound()
if user_id:
end_user = db.session.scalar(
select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id)
)
if end_user:
pass
else:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=user_id,
)
db.session.add(end_user)
db.session.commit()
else:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=generate_session_id(),
)
db.session.add(end_user)
db.session.commit()
payload = {
"iss": site.app_id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": app_code,
"end_user_id": end_user.id,
}
tk = PassportService().issue(payload)
return dump_response(PassportAccessTokenResponse, {"access_token": tk})
def decode_enterprise_webapp_user_id(jwt_token: str | None) -> dict[str, Any] | None:
"""
Decode the enterprise user session from the Authorization header.
"""
if not jwt_token:
return None
decoded: dict[str, Any] = PassportService().verify(jwt_token)
source = decoded.get("token_source")
if not source or source != "webapp_login_token":
raise Unauthorized("Invalid token source. Expected 'webapp_login_token'.")
return decoded
def exchange_token_for_existing_web_user(
app_code: str, enterprise_user_decoded: dict[str, Any], auth_type: WebAppAuthType
):
"""
Exchange a token for an existing web user session.
"""
user_id = enterprise_user_decoded.get("user_id")
end_user_id = enterprise_user_decoded.get("end_user_id")
session_id = enterprise_user_decoded.get("session_id")
user_auth_type = enterprise_user_decoded.get("auth_type")
exchanged_token_expires_unix = enterprise_user_decoded.get("exp")
if not user_auth_type:
raise Unauthorized("Missing auth_type in the token.")
site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal"))
if not site:
raise NotFound()
app_model = db.session.scalar(select(App).where(App.id == site.app_id))
if not app_model or app_model.status != "normal" or not app_model.enable_site:
raise NotFound()
match auth_type:
case WebAppAuthType.PUBLIC:
return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded)
case WebAppAuthType.EXTERNAL:
if user_auth_type != "external":
raise WebAppAuthRequiredError("Please login as external user.")
case WebAppAuthType.INTERNAL:
if user_auth_type != "internal":
raise WebAppAuthRequiredError("Please login as internal user.")
end_user = None
if end_user_id:
end_user = db.session.scalar(select(EndUser).where(EndUser.id == end_user_id))
if session_id:
end_user = db.session.scalar(
select(EndUser).where(
EndUser.session_id == session_id,
EndUser.tenant_id == app_model.tenant_id,
EndUser.app_id == app_model.id,
)
query = PassportQuery.model_validate(request.args.to_dict(flat=True))
passport_request = WebPassportRequest(
app_code=app_code,
user_session_id=query.user_id,
access_token=extract_webapp_access_token(request),
)
if not end_user:
if not session_id:
raise NotFound("Missing session_id for existing web user.")
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=session_id,
)
db.session.add(end_user)
db.session.commit()
try:
result = application_services().web_passport.issue(passport_request)
except WebPassportAuthenticationRequiredError as exc:
raise WebAppAuthRequiredError(str(exc)) from exc
except WebPassportUnauthorizedError as exc:
raise Unauthorized(str(exc)) from exc
except WebPassportNotFoundError as exc:
raise NotFound(str(exc) or None) from exc
exp = int((datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp())
if exchanged_token_expires_unix:
exp = int(exchanged_token_expires_unix)
payload = {
"iss": site.id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": site.code,
"user_id": user_id,
"end_user_id": end_user.id,
"auth_type": user_auth_type,
"granted_at": int(datetime.now(UTC).timestamp()),
"token_source": "webapp",
"exp": exp,
}
token: str = PassportService().issue(payload)
return {"access_token": token}
def _exchange_for_public_app_token(app_model, site, token_decoded):
user_id = token_decoded.get("user_id")
end_user = None
if user_id:
end_user = db.session.scalar(
select(EndUser).where(EndUser.app_id == app_model.id, EndUser.session_id == user_id)
)
if not end_user:
end_user = EndUser(
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=generate_session_id(),
)
db.session.add(end_user)
db.session.commit()
payload = {
"iss": site.app_id,
"sub": "Web API Passport",
"app_id": site.app_id,
"app_code": site.code,
"end_user_id": end_user.id,
}
tk = PassportService().issue(payload)
return {"access_token": tk}
def generate_session_id():
"""
Generate a unique session ID.
"""
while True:
session_id = str(uuid.uuid4())
existing_count = db.session.scalar(
select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id)
)
if existing_count == 0:
return session_id
return dump_response(PassportAccessTokenResponse, {"access_token": result.access_token})

View File

@ -18,7 +18,7 @@ from libs.token import extract_webapp_passport
from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from services.webapp_auth_service import WebAppAuthService
@ -44,7 +44,7 @@ def validate_jwt_token[**P, R](
def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) -> tuple[App, EndUser]:
system_features = FeatureService.get_system_features()
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled()
if not app_code:
app_code = str(request.headers.get(HEADER_NAME_APP_CODE))
try:
@ -75,21 +75,19 @@ def decode_jwt_token(app_code: str | None = None, user_id: str | None = None) ->
# for enterprise webapp auth
app_web_auth_enabled = False
webapp_settings = None
if system_features.webapp_auth.enabled:
if webapp_auth_enabled:
app_id = AppService.get_app_id_by_code(app_code, session=db.session())
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id)
if not webapp_settings:
raise NotFound("Web app settings not found.")
app_web_auth_enabled = webapp_settings.access_mode != WebAppAccessMode.PUBLIC
_validate_webapp_token(decoded, app_web_auth_enabled, system_features.webapp_auth.enabled)
_validate_user_accessibility(
decoded, app_code, app_web_auth_enabled, system_features.webapp_auth.enabled, webapp_settings
)
_validate_webapp_token(decoded, app_web_auth_enabled, webapp_auth_enabled)
_validate_user_accessibility(decoded, app_code, app_web_auth_enabled, webapp_auth_enabled, webapp_settings)
return app_model, end_user
except Unauthorized as e:
if system_features.webapp_auth.enabled:
if webapp_auth_enabled:
if not app_code:
raise Unauthorized("Please re-login to access the web app.")
app_id = AppService.get_app_id_by_code(app_code, session=db.session())

View File

@ -69,9 +69,9 @@ def check_credential_policy_compliance(
CheckCredentialPolicyComplianceRequest,
PluginManagerService,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
if not FeatureService.is_plugin_manager_enabled() or not credential_id:
if not SystemFeatureService.is_plugin_manager_enabled() or not credential_id:
return
# Check if credential exists in database first (if requested)

View File

@ -69,7 +69,7 @@ from services.enterprise.plugin_manager_service import (
)
from services.entities.feature_entities import PluginInstallationPermissionModel, PluginInstallationScope
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
_provider_entities_adapter: TypeAdapter[list[PluginModelProviderDeclaration]] = TypeAdapter(
@ -667,7 +667,7 @@ class PluginService:
@staticmethod
def _get_plugin_installation_permission() -> PluginInstallationPermissionModel:
"""Resolve the validated policy and reject deny-all before any installation side effect."""
permission = FeatureService.get_plugin_installation_permission()
permission = SystemFeatureService.get_plugin_installation_permission()
if permission.plugin_installation_scope == PluginInstallationScope.NONE:
raise PluginInstallationForbiddenError("Installing plugins is not allowed")
return permission

View File

@ -6,9 +6,17 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import (
AgentFileRefConfig,
AgentHumanContactConfig,
@ -127,21 +135,28 @@ class WorkflowAgentNodeValidator:
topology: _WorkflowGraphTopology | None = None,
require_agent_model: bool = True,
) -> None:
"""Validate binding ownership, publication state, Agent Soul, and node-job references."""
if binding.agent_id is None:
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.")
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
)
.limit(1)
agent_stmt = select(Agent).where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
)
if agent is None or agent.status == AgentStatus.ARCHIVED:
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references an unavailable agent."
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
agent_stmt = agent_stmt.where(
Agent.scope == AgentScope.ROSTER,
workflow_callable_active_snapshot_filter(),
)
agent = session.scalar(agent_stmt.limit(1))
if agent is None or agent.status == AgentStatus.ARCHIVED:
availability = (
"an unavailable or unpublished roster agent"
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else "an unavailable agent"
)
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} references {availability}.")
snapshot_id = (
agent.active_config_snapshot_id

View File

@ -3,7 +3,9 @@
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import cast
from uuid import uuid4
import httpx
from flask import Flask, current_app
@ -19,6 +21,7 @@ from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter
from libs.passport import PassportService
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_repository import SQLAlchemyAccountRepository
@ -35,6 +38,7 @@ from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourSt
from repositories.tag_repository import TagRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
from repositories.web_passport_repository import WebPassportRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
from repositories.workflow_run_archive_repository import WorkflowRunArchiveBundleQueryRepository
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
@ -101,10 +105,10 @@ from services.enterprise.enterprise_service import EnterpriseService
from services.errors.enterprise import EnterpriseServiceError
from services.explore_banner_query_service import ExploreBannerQueryService
from services.feature_query_service import FeatureQueryService
from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
from services.file_service import FileService
from services.init_validation_service import InitValidationService
from services.inner_mail_service import InnerMailService
from services.notification_gateway import BillingNotificationGateway
from services.notification_service import NotificationService
from services.notion_data_source_gateway import NotionDataSourceGateway
@ -126,9 +130,15 @@ from services.schema_definition_service import SchemaDefinitionService
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
from services.setup_service import SetupService
from services.step_by_step_tour_service import StepByStepTourService
from services.system_feature_service import SystemFeatureService
from services.tag_application_service import TagApplicationService
from services.trial_app_usage import TrialAppUsageRecorder
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
from services.web_passport_gateways import (
DeploymentWebPassportAuthGateway,
PassportTokenGateway,
)
from services.web_passport_service import WebPassportService
from services.webapp_access_query_service import (
WebAppAccessQueryService,
WebAppAccessUnavailableError,
@ -138,6 +148,7 @@ from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
from tasks.mail_inner_task import enqueue_inner_mail
_EXTENSION_KEY = "application_services"
@ -200,6 +211,8 @@ class ApplicationServices:
workflow_run_archives: WorkflowRunArchiveService
workspace_queries: WorkspaceQueryService
workspace_member_queries: WorkspaceMemberQueryService
inner_mail: InnerMailService
web_passport: WebPassportService
tags: TagApplicationService
workflow_statistics: WorkflowStatisticQueryService
@ -257,7 +270,7 @@ def build_application_services(
feature_gateway = FeatureServiceGateway()
accounts = SQLAlchemyAccountRepository(session_factory=database_client)
integrations = SQLAlchemyAccountIntegrationRepository(session_factory=database_client)
trial_app_enabled = FeatureService.is_trial_app_enabled()
trial_app_enabled = SystemFeatureService.is_trial_app_enabled()
database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis)
builtin_catalog = BuiltinRecommendedAppCatalogGateway()
remote_catalog = RemoteRecommendedAppCatalogGateway()
@ -409,7 +422,7 @@ def build_application_services(
data_source_oauth=_build_data_source_oauth_services(database_client=database_client),
webapp_access=WebAppAccessQueryService(
access=WebAppAccessQueryRepository(session_factory=database_client),
webapp_auth_enabled=FeatureService.is_webapp_auth_enabled(),
webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition),
access_mode_for_app=_get_enterprise_webapp_access_mode,
is_user_allowed_for_app=_is_user_allowed_to_access_webapp,
),
@ -421,7 +434,7 @@ def build_application_services(
),
explore_banner_queries=ExploreBannerQueryService(
banners=ExploreBannerQueryRepository(session_factory=database_client),
enabled=FeatureService.is_explore_banner_enabled(),
enabled=SystemFeatureService.is_explore_banner_enabled(),
),
schema_definitions=SchemaDefinitionService(source_factory=SchemaManager),
setup=SetupService(
@ -475,6 +488,20 @@ def build_application_services(
),
roles=DeploymentWorkspaceMemberRoleResolver(),
),
inner_mail=InnerMailService(dispatch=enqueue_inner_mail),
web_passport=WebPassportService(
passports=WebPassportRepository(
session_factory=database_client,
generate_session_id=lambda: str(uuid4()),
),
auth=DeploymentWebPassportAuthGateway(
webapp_auth_enabled=SystemFeatureService.is_webapp_auth_enabled(deployment_edition=deployment_edition),
get_app_access_mode=EnterpriseService.WebAppAuth.get_app_access_mode_by_id,
),
tokens=PassportTokenGateway(passport=PassportService()),
now=lambda: datetime.now(UTC),
access_token_expire_minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES,
),
tags=TagApplicationService(
tags=TagRepository(session_factory=database_client),
),

View File

@ -18,7 +18,7 @@ from werkzeug.exceptions import NotFound
from libs import jws
from libs.token import is_secure
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
@ -40,8 +40,7 @@ def enterprise_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
settings = FeatureService.get_system_features()
if settings.license.status not in _EE_ENABLED_STATUSES:
if SystemFeatureService.get_license_status() not in _EE_ENABLED_STATUSES:
raise NotFound()
return view(*args, **kwargs)

View File

@ -17,7 +17,7 @@ from pydantic import BaseModel, Field
from extensions.ext_mail import mail
from services.entities.feature_entities import BrandingModel
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class EmailType(StrEnum):
@ -136,7 +136,7 @@ class FeatureBrandingService:
def get_branding_config(self) -> BrandingModel:
"""Get branding configuration from feature service."""
return FeatureService.get_system_features().branding
return SystemFeatureService.get_branding()
class EmailSender(Protocol):

View File

@ -3,7 +3,7 @@ Workspace permission helper functions.
These helpers check both billing/plan level and workspace-specific policy level permissions.
Checks are performed at two levels:
1. Billing/plan level - via FeatureService (e.g., SANDBOX plan restrictions)
1. Billing/plan level - via an injected owner-transfer policy value
2. Workspace policy level - via EnterpriseService (admin-configured per workspace)
"""
@ -14,7 +14,6 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
@ -45,7 +44,11 @@ def check_workspace_member_invite_permission(workspace_id: str) -> None:
logger.exception("Failed to check workspace invite permission for %s", workspace_id)
def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
def check_workspace_owner_transfer_permission(
workspace_id: str,
*,
owner_transfer_allowed: bool,
) -> None:
"""
Check if workspace allows owner transfer at both billing and policy levels.
@ -55,12 +58,12 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None:
Args:
workspace_id: The workspace ID to check permissions for
owner_transfer_allowed: Whether the workspace plan permits ownership transfer
Raises:
Forbidden: If either billing plan or workspace policy prohibits ownership transfer
"""
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
if not features.is_allow_transfer_workspace:
if not owner_transfer_allowed:
raise Forbidden("Your current plan does not allow workspace ownership transfer")
# Check the enterprise workspace policy only in the Enterprise edition.

View File

@ -212,17 +212,9 @@ class Dataset(Base):
enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=sa.text("false"))
@property
def total_documents(self) -> int:
return self.get_total_documents(session=db.session())
def get_total_documents(self, *, session: Session) -> int:
return self.get_document_count(session=session)
@property
def total_available_documents(self) -> int:
return self.get_total_available_documents(session=db.session())
def get_total_available_documents(self, *, session: Session) -> int:
return (
session.scalar(
@ -258,20 +250,12 @@ class Dataset(Base):
def get_created_by_account(self, *, session: Session) -> Account | None:
return session.get(Account, self.created_by)
@property
def author_name(self) -> str | None:
return self.get_author_name(session=db.session())
def get_author_name(self, *, session: Session) -> str | None:
account = self.get_created_by_account(session=session)
if account:
return account.name
return None
@property
def latest_process_rule(self):
return self.get_latest_process_rule(session=db.session())
def get_latest_process_rule(self, *, session: Session) -> "DatasetProcessRule | None":
return session.scalar(
select(DatasetProcessRule)
@ -391,10 +375,6 @@ class Dataset(Base):
return tags or []
@property
def external_knowledge_info(self) -> dict[str, Any] | None:
return self.get_external_knowledge_info(session=db.session())
def get_external_knowledge_info(self, *, session: Session) -> dict[str, Any] | None:
if self.provider != "external":
return None
@ -974,17 +954,15 @@ class DocumentSegment(TypeBase):
"""Load the owning document with the caller-owned database session."""
return session.get(Document, self.document_id)
@property
def previous_segment(self):
return db.session.scalar(
def previous_segment(self, session: Session) -> "DocumentSegment | None":
return session.scalar(
select(DocumentSegment).where(
DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position - 1
)
)
@property
def next_segment(self):
return db.session.scalar(
def next_segment(self, session: Session) -> "DocumentSegment | None":
return session.scalar(
select(DocumentSegment).where(
DocumentSegment.document_id == self.document_id, DocumentSegment.position == self.position + 1
)
@ -1204,9 +1182,8 @@ class AppDatasetJoin(TypeBase):
DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
@property
def app(self):
return db.session.get(App, self.app_id)
def app(self, session: Session) -> App | None:
return session.get(App, self.app_id)
class DatasetQuery(TypeBase):

View File

@ -217,7 +217,7 @@ storage = [
############################################################
# [ Tools ] dependency group
############################################################
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.0,<4.0.0"]
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.10.3,<4.0.0"]
############################################################
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*

View File

@ -0,0 +1,150 @@
"""SQLAlchemy persistence adapter for web passport issuance."""
from collections.abc import Callable
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker
from models.enums import AppStatus, EndUserType
from models.model import App, EndUser, Site
from services.entities.passport_entities import EndUserRecord, WebAppRecord, WebPassportEndUserResolution
class WebPassportRepository:
def __init__(
self,
*,
session_factory: sessionmaker[Session],
generate_session_id: Callable[[], str],
) -> None:
self._session_factory = session_factory
self._generate_session_id = generate_session_id
def get_active_web_app(self, app_code: str) -> WebAppRecord | None:
stmt = self._active_web_app_stmt(app_code).limit(1)
with self._session_factory() as session:
row = session.execute(stmt).one_or_none()
if row is None:
return None
site_id, app_id, tenant_id, persisted_app_code = row
return WebAppRecord(
site_id=str(site_id),
app_id=str(app_id),
tenant_id=str(tenant_id),
app_code=str(persisted_app_code),
)
def is_web_app_active(self, app: WebAppRecord) -> bool:
with self._session_factory() as session:
return self._is_web_app_active(session, app)
def resolve_standard_end_user(
self,
app: WebAppRecord,
session_id: str | None,
) -> WebPassportEndUserResolution:
with self._session_factory.begin() as session:
if not self._is_web_app_active(session, app):
return WebPassportEndUserResolution(app_active=False, end_user=None)
if session_id:
end_user = self._find_end_user_by_session_id(session, app, session_id)
if end_user is not None:
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
else:
session_id = self._generate_unique_session_id(session)
end_user = self._create_anonymous_end_user(session, app, session_id)
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
def resolve_authenticated_end_user(
self,
app: WebAppRecord,
*,
end_user_id: str | None,
session_id: str | None,
) -> WebPassportEndUserResolution:
with self._session_factory.begin() as session:
if not self._is_web_app_active(session, app):
return WebPassportEndUserResolution(app_active=False, end_user=None)
end_user = None
if session_id:
end_user = self._find_end_user_by_session_id(session, app, session_id)
if end_user is None:
end_user = self._create_anonymous_end_user(session, app, session_id)
elif end_user_id:
end_user = self._find_end_user_by_id(session, app, end_user_id)
return WebPassportEndUserResolution(app_active=True, end_user=end_user)
@staticmethod
def _active_web_app_stmt(app_code: str):
return (
select(Site.id, App.id, App.tenant_id, Site.code)
.join(App, App.id == Site.app_id)
.where(
Site.code == app_code,
Site.status == AppStatus.NORMAL,
App.status == AppStatus.NORMAL,
App.enable_site.is_(True),
)
)
def _is_web_app_active(self, session: Session, app: WebAppRecord) -> bool:
stmt = self._active_web_app_stmt(app.app_code).where(
Site.id == app.site_id,
App.id == app.app_id,
App.tenant_id == app.tenant_id,
)
return session.execute(stmt.limit(1)).one_or_none() is not None
@staticmethod
def _find_end_user_by_id(session: Session, app: WebAppRecord, end_user_id: str) -> EndUserRecord | None:
persisted_id = session.scalar(
select(EndUser.id).where(
EndUser.id == end_user_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.app_id,
)
)
return EndUserRecord(id=persisted_id) if persisted_id is not None else None
@staticmethod
def _find_end_user_by_session_id(
session: Session,
app: WebAppRecord,
session_id: str,
) -> EndUserRecord | None:
end_user_id = session.scalar(
select(EndUser.id).where(
EndUser.session_id == session_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.app_id,
)
)
return EndUserRecord(id=end_user_id) if end_user_id is not None else None
@staticmethod
def _create_anonymous_end_user(
session: Session,
app: WebAppRecord,
session_id: str,
) -> EndUserRecord:
end_user = EndUser(
tenant_id=app.tenant_id,
app_id=app.app_id,
type=EndUserType.BROWSER,
is_anonymous=True,
session_id=session_id,
)
session.add(end_user)
session.flush()
return EndUserRecord(id=end_user.id)
def _generate_unique_session_id(self, session: Session) -> str:
while True:
session_id = self._generate_session_id()
stmt = select(func.count()).select_from(EndUser).where(EndUser.session_id == session_id)
if not session.scalar(stmt):
return session_id

View File

@ -79,8 +79,8 @@ from services.errors.account import (
SeatsLimitExceededError,
)
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.system_feature_service import SystemFeatureService
from services.telemetry_service import CommunityTelemetryService
from tasks.mail_change_mail_task import (
send_change_mail_completed_notification_task,
@ -446,7 +446,7 @@ class AccountService:
session: Session,
) -> Account:
"""Create an account, preferring explicit user timezone over language-derived defaults."""
if not FeatureService.get_system_features().is_allow_register and not is_setup:
if not SystemFeatureService.is_registration_allowed() and not is_setup:
from controllers.console.error import AccountNotFound
raise AccountNotFound()
@ -458,7 +458,7 @@ class AccountService:
# account into another workspace does not pass through here and costs no seat.
# get_license() carries the full license payload that server-side enforcement needs;
# the public system-features endpoint exposes only license status.
if not FeatureService.get_license().seats.is_available():
if not SystemFeatureService.get_license().seats.is_available():
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email):
@ -1139,7 +1139,7 @@ class TenantService:
session: Session,
) -> Tenant:
"""Create tenant"""
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
from controllers.console.error import NotAllowedCreateWorkspace
raise NotAllowedCreateWorkspace()
@ -1202,10 +1202,10 @@ 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.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if not SystemFeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_license().workspaces
workspaces = SystemFeatureService.get_license().workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceededError()
@ -1988,9 +1988,9 @@ class RegisterService:
AccountService.link_account_integrate(provider, open_id, account, session=session)
if (
FeatureService.is_workspace_creation_allowed()
SystemFeatureService.is_workspace_creation_allowed()
and create_workspace_required
and FeatureService.get_license().workspaces.is_available()
and SystemFeatureService.get_license().workspaces.is_available()
):
try:
TenantService.create_owner_tenant(account, session=session)

View File

@ -44,7 +44,7 @@ from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentW
from services.app_service import AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
logger = logging.getLogger(__name__)
@ -1120,7 +1120,7 @@ class AgentRosterService:
source_include_draft=not source_agent.active_config_is_published,
)
self._session.commit()
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
try:
original_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(source_app.id)
access_mode = original_settings.access_mode

View File

@ -49,8 +49,8 @@ from services.agent.workspace_service import AgentWorkspaceService
from services.billing_service import BillingService
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
from services.system_feature_service import SystemFeatureService
from services.tag_service import TagService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
@ -703,7 +703,7 @@ class AppService:
app.id,
)
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
# update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
@ -1155,7 +1155,7 @@ class AppService:
)
# clean up web app settings
if FeatureService.get_system_features().webapp_auth.enabled:
if SystemFeatureService.is_webapp_auth_enabled():
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:

View File

@ -478,7 +478,6 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
_LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.preview",
"app.acl.access_point_manage",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@ -494,7 +493,6 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
_LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.access_point_manage",
"app.acl.test_and_run",
"app.acl.edit",
"app.acl.import_export_dsl",
@ -509,7 +507,6 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
_LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.preview",
"app.acl.access_point_manage",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",

View File

@ -0,0 +1,12 @@
"""Framework-neutral data contracts for internal mail delivery."""
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True, slots=True)
class InnerMailMessage:
recipients: tuple[str, ...]
subject: str
body: str
substitutions: dict[str, Any] | None = None

View File

@ -0,0 +1,47 @@
"""Framework-neutral data contracts for web passport issuance."""
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict
@dataclass(frozen=True, slots=True)
class WebAppRecord:
site_id: str
app_id: str
tenant_id: str
app_code: str
@dataclass(frozen=True, slots=True)
class EndUserRecord:
id: str
@dataclass(frozen=True, slots=True)
class WebPassportEndUserResolution:
app_active: bool
end_user: EndUserRecord | None
@dataclass(frozen=True, slots=True)
class WebPassportRequest:
app_code: str
user_session_id: str | None
access_token: str | None
@dataclass(frozen=True, slots=True)
class WebPassportResult:
access_token: str
class WebAppLoginClaims(BaseModel):
token_source: str | None = None
user_id: str | None = None
end_user_id: str | None = None
session_id: str | None = None
auth_type: str | None = None
exp: int | None = None
model_config = ConfigDict(extra="ignore")

View File

@ -36,10 +36,16 @@ class FeatureQueryService:
self._app_dsl_version = app_dsl_version
def get_features(self, context: RequestContext) -> FeatureModel:
return self._features.get_workspace_features(self._require_active_workspace(context))
return self.get_workspace_features(self._require_active_workspace(context))
def get_workspace_features(self, workspace_id: str) -> FeatureModel:
return self._features.get_workspace_features(workspace_id)
def get_vector_space(self, context: RequestContext) -> VectorSpaceLimitationModel:
return self._features.get_vector_space(self._require_active_workspace(context))
return self.get_workspace_vector_space(self._require_active_workspace(context))
def get_workspace_vector_space(self, workspace_id: str) -> VectorSpaceLimitationModel:
return self._features.get_vector_space(workspace_id)
def get_trial_models(self, context: RequestContext) -> list[str]:
return self._features.get_trial_models(self._require_active_workspace(context))
@ -47,7 +53,7 @@ class FeatureQueryService:
def get_app_dsl_version(self) -> str:
return self._app_dsl_version
def get_system_features(self) -> SystemFeatureModel:
def get_public_system_features(self) -> SystemFeatureModel:
return self._features.get_public_system_features()
def get_license(self) -> LicenseModel:

View File

@ -1,23 +1,9 @@
import logging
from collections.abc import Mapping
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from enums import CloudPlan, DeploymentEdition, HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
logger = logging.getLogger(__name__)
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class FeatureService:
@classmethod
@ -102,104 +88,6 @@ class FeatureService:
return False
return features.billing.enabled and features.billing.subscription.plan.is_paid
@classmethod
def get_system_features(cls) -> feature_entities.SystemFeatureModel:
system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
system_features.webapp_auth.enabled = cls.is_webapp_auth_enabled()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
system_features.branding.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
system_features.enable_creators_platform = True
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 dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
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.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@classmethod
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> feature_entities.LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseModel()
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
return license_model
@staticmethod
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
@staticmethod
def is_webapp_auth_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@staticmethod
def is_trial_app_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
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_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_explore_banner = cls.is_explore_banner_enabled()
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
knowledge_fs_enabled = bool(
dify_config.KNOWLEDGE_FS_ENABLED
and (
dify_config.DEPLOYMENT_EDITION in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE}
or dify_config.KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED
)
)
system_features.knowledge_fs_enabled = knowledge_fs_enabled
system_features.knowledge_fs_upload_enabled = bool(
knowledge_fs_enabled
and dify_config.KNOWLEDGE_FS_BASE_URL
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM
)
@classmethod
def _fulfill_trial_models_from_env(cls, quota_types: tuple[str, ...] | None = None) -> list[str]:
allowed_quota_types = quota_types or ("PAID", "TRIAL")
@ -325,125 +213,3 @@ class FeatureService:
# but feature API keeps LimitationModel.size as int for compatibility.
vector_space.size = int(billing_info["vector_space"]["size"])
vector_space.limit = billing_info["vector_space"]["limit"]
@classmethod
def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel:
license_model = feature_entities.LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = feature_entities.LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = feature_entities.LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> feature_entities.PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return feature_entities.PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
# Do not attach the exception because it may contain raw Enterprise configuration values.
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=feature_entities.PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@staticmethod
def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
if not isinstance(value, str):
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name)
return None
try:
return feature_entities.SSOProtocol(value)
except ValueError:
logger.error( # noqa: TRY400
"Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name
)
return None
@classmethod
def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForSigninProtocol"),
field_name="SSOEnforcedForSigninProtocol",
)
if "EnableEmailCodeLogin" in enterprise_info:
features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
if "EnableEmailPasswordLogin" in enterprise_info:
features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
if "Branding" in enterprise_info:
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
if "WebAppAuth" in enterprise_info:
features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
"allowEmailCodeLogin", False
)
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
"allowEmailPasswordLogin", False
)
features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForWebProtocol"),
field_name="SSOEnforcedForWebProtocol",
)
# SECURITY NOTE: system-features is unauthenticated, so it exposes only license
# *status* — enough for the login page to detect an expired/inactive license after
# force-logout. Full license detail (expiry, workspace/seat usage) is served
# separately by get_license() behind an authenticated endpoint.
if license_info := enterprise_info.get("License"):
features.license = feature_entities.LicenseStatusModel(
status=feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)

View File

@ -1,4 +1,4 @@
"""Feature-query gateway backed by the existing FeatureService."""
"""Feature-query gateway combining workspace and deployment feature providers."""
from typing import override
@ -10,10 +10,11 @@ from services.entities.feature_entities import (
)
from services.feature_query_service import FeatureQueryGateway
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class FeatureServiceGateway(FeatureQueryGateway):
"""Read dynamic feature resources through FeatureService."""
"""Read workspace features from FeatureService and deployment features from SystemFeatureService."""
@override
def get_workspace_features(self, workspace_id: str) -> FeatureModel:
@ -29,8 +30,8 @@ class FeatureServiceGateway(FeatureQueryGateway):
@override
def get_public_system_features(self) -> SystemFeatureModel:
return FeatureService.get_system_features()
return SystemFeatureService.get_public_system_features()
@override
def get_license(self) -> LicenseModel:
return FeatureService.get_license()
return SystemFeatureService.get_license()

View File

@ -0,0 +1,17 @@
"""Application service for mail received through the inner API."""
from typing import Protocol
from services.entities.mail_entities import InnerMailMessage
class InnerMailDispatcher(Protocol):
def __call__(self, message: InnerMailMessage) -> None: ...
class InnerMailService:
def __init__(self, *, dispatch: InnerMailDispatcher) -> None:
self._dispatch = dispatch
def send(self, message: InnerMailMessage) -> None:
self._dispatch(message)

View File

@ -8,7 +8,7 @@ from libs.helper import escape_like_pattern
from models import App, AppModelConfig, InstalledApp, Workflow
from models.model import AppMode
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class InstalledAppCursor(BaseModel):
@ -118,7 +118,7 @@ class InstalledAppService:
escaped_name = escape_like_pattern(normalized_name)
stmt = stmt.where(App.name.ilike(f"%{escaped_name}%", escape="\\"))
webapp_auth_enabled = FeatureService.get_system_features().webapp_auth.enabled
webapp_auth_enabled = SystemFeatureService.is_webapp_auth_enabled()
scan_size = limit * 2 if webapp_auth_enabled else limit + 1
visible_rows: list[tuple[InstalledApp, App]] = []
scan_cursor = cursor

View File

@ -5,8 +5,7 @@ the EE blueprint chain is what gives CE deploys no callers on this surface
in practice, but the explicit short-circuit avoids any test/fixture that
flips the surface on without flipping the license.
Reuses ``FeatureService.get_system_features()`` so the license status
travels the same path as the console reads.
Uses the narrow system license policy shared with Console admission.
Companion to ``controllers.console.wraps.enterprise_license_required``
that one is for console (cookie-authed, force-logout 401). This one is
@ -24,7 +23,7 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from enums import DeploymentEdition
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
logger = logging.getLogger(__name__)
@ -47,8 +46,8 @@ def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def _is_license_valid() -> bool:
try:
features = FeatureService.get_system_features()
license_status = SystemFeatureService.get_license_status()
except Exception:
logger.exception("license_gate: FeatureService.get_system_features failed")
logger.exception("license_gate: SystemFeatureService.get_license_status failed")
return False
return features.license.status in _VALID_LICENSE_STATUSES
return license_status in _VALID_LICENSE_STATUSES

View File

@ -0,0 +1,301 @@
"""Deployment-wide feature policies and the public system-features snapshot."""
import logging
from collections.abc import Mapping
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from enums import DeploymentEdition
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
logger = logging.getLogger(__name__)
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class SystemFeatureService:
"""Resolve deployment-wide policies without exposing the public response DTO internally."""
@classmethod
def get_public_system_features(cls) -> feature_entities.SystemFeatureModel:
"""Build the non-sensitive bootstrap snapshot shared by Console and Web."""
system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
if cls.is_webapp_auth_enabled():
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
system_features.enable_creators_platform = True
return system_features
@classmethod
def is_registration_allowed(cls) -> bool:
"""Return the effective registration policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_REGISTER
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowRegister", is_allowed))
@classmethod
def is_email_password_login_enabled(cls) -> bool:
"""Return the effective password-login policy, including the Enterprise override."""
is_enabled = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_enabled
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("EnableEmailPasswordLogin", is_enabled))
@staticmethod
def is_change_email_enabled() -> bool:
"""Return whether Console accounts may change their email address."""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
return False
return dify_config.ENABLE_CHANGE_EMAIL
@staticmethod
def is_webapp_auth_enabled(*, deployment_edition: DeploymentEdition | None = None) -> bool:
"""Return whether deployment-level WebApp authentication integration is enabled."""
edition = deployment_edition if deployment_edition is not None else dify_config.DEPLOYMENT_EDITION
return edition == DeploymentEdition.ENTERPRISE
@classmethod
def get_license_status(cls) -> feature_entities.LicenseStatus:
"""Return the deployment license status used by internal admission policies."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseStatus.NONE
return cls._resolve_license_status(EnterpriseService.get_info())
@classmethod
def get_branding(cls) -> feature_entities.BrandingModel:
"""Return the deployment branding used by server-rendered email."""
branding = feature_entities.BrandingModel(enabled=cls.is_webapp_auth_enabled())
if not branding.enabled:
return branding
enterprise_info = EnterpriseService.get_info()
if branding_info := enterprise_info.get("Branding"):
branding.application_title = branding_info.get("applicationTitle", "")
branding.login_page_logo = branding_info.get("loginPageLogo", "")
branding.workspace_logo = branding_info.get("workspaceLogo", "")
branding.favicon = branding_info.get("favicon", "")
return branding
@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 dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed))
@staticmethod
def is_plugin_manager_enabled() -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE
@classmethod
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> feature_entities.LicenseModel:
"""Return full license detail for authenticated server-side consumers."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return feature_entities.LicenseModel()
license_model = cls._build_license(EnterpriseService.get_info())
license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE
return license_model
@staticmethod
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
@staticmethod
def is_trial_app_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel) -> None:
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
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_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_explore_banner = cls.is_explore_banner_enabled()
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
knowledge_fs_enabled = bool(
dify_config.KNOWLEDGE_FS_ENABLED
and (
dify_config.DEPLOYMENT_EDITION in {DeploymentEdition.CLOUD, DeploymentEdition.ENTERPRISE}
or dify_config.KNOWLEDGE_FS_COMMUNITY_DEV_ENABLED
)
)
system_features.knowledge_fs_enabled = knowledge_fs_enabled
system_features.knowledge_fs_upload_enabled = bool(
knowledge_fs_enabled
and dify_config.KNOWLEDGE_FS_BASE_URL
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_ENABLED
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_SIGNING_KID
and dify_config.KNOWLEDGE_FS_CAPABILITY_V2_PRIVATE_KEY_PEM
)
@classmethod
def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel) -> None:
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
features.sso_enforced_for_signin = enterprise_info["SSOEnforcedForSignin"]
features.sso_enforced_for_signin_protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForSigninProtocol"),
field_name="SSOEnforcedForSigninProtocol",
)
if "EnableEmailCodeLogin" in enterprise_info:
features.enable_email_code_login = enterprise_info["EnableEmailCodeLogin"]
if "EnableEmailPasswordLogin" in enterprise_info:
features.enable_email_password_login = enterprise_info["EnableEmailPasswordLogin"]
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
if "Branding" in enterprise_info:
features.branding.application_title = enterprise_info["Branding"].get("applicationTitle", "")
features.branding.login_page_logo = enterprise_info["Branding"].get("loginPageLogo", "")
features.branding.workspace_logo = enterprise_info["Branding"].get("workspaceLogo", "")
features.branding.favicon = enterprise_info["Branding"].get("favicon", "")
if "WebAppAuth" in enterprise_info:
features.webapp_auth.allow_sso = enterprise_info["WebAppAuth"].get("allowSso", False)
features.webapp_auth.allow_email_code_login = enterprise_info["WebAppAuth"].get(
"allowEmailCodeLogin", False
)
features.webapp_auth.allow_email_password_login = enterprise_info["WebAppAuth"].get(
"allowEmailPasswordLogin", False
)
features.webapp_auth.sso_config.protocol = cls._resolve_sso_protocol(
enterprise_info.get("SSOEnforcedForWebProtocol"),
field_name="SSOEnforcedForWebProtocol",
)
# The unauthenticated endpoint exposes status only. Full license detail is
# served by the authenticated license endpoint.
license_status = cls._resolve_license_status(enterprise_info)
if license_status != feature_entities.LicenseStatus.NONE:
features.license = feature_entities.LicenseStatusModel(
status=license_status,
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
@staticmethod
def _resolve_license_status(enterprise_info: Mapping[str, object]) -> feature_entities.LicenseStatus:
license_info = enterprise_info.get("License")
if not license_info:
return feature_entities.LicenseStatus.NONE
if not isinstance(license_info, Mapping):
return feature_entities.LicenseStatus.INACTIVE
status = license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
if isinstance(status, feature_entities.LicenseStatus):
return status
if isinstance(status, str):
return feature_entities.LicenseStatus(status)
return feature_entities.LicenseStatus.INACTIVE
@classmethod
def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel:
license_model = feature_entities.LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = feature_entities.LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = feature_entities.LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> feature_entities.PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return feature_entities.PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
enterprise_info["PluginInstallationPermission"]
)
except ValidationError as exc:
logger.error( # noqa: TRY400
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=feature_entities.PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@staticmethod
def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
if not isinstance(value, str):
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name)
return None
try:
return feature_entities.SSOProtocol(value)
except ValueError:
logger.error("Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name) # noqa: TRY400
return None

View File

@ -0,0 +1,49 @@
"""Outer gateways used by the web passport application service."""
from collections.abc import Callable, Mapping
from typing import Any
from werkzeug.exceptions import Unauthorized
from libs.passport import PassportService
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, WebAppAccessMode, WebAppSettings
from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError
class DeploymentWebPassportAuthGateway:
def __init__(
self,
*,
webapp_auth_enabled: bool,
get_app_access_mode: Callable[[str], WebAppSettings],
) -> None:
self._webapp_auth_enabled = webapp_auth_enabled
self._get_app_access_mode = get_app_access_mode
def is_webapp_auth_enabled(self) -> bool:
return self._webapp_auth_enabled
def get_app_auth_type(self, app_id: str) -> WebAppAuthType:
access_mode = self._get_app_access_mode(app_id).access_mode
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
if access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
if access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
raise ValueError(f"Unsupported web app access mode: {access_mode}")
class PassportTokenGateway:
def __init__(self, *, passport: PassportService) -> None:
self._passport = passport
def verify(self, token: str) -> Mapping[str, Any]:
try:
return self._passport.verify(token)
except Unauthorized as exc:
description = exc.description or "Invalid token."
raise WebPassportUnauthorizedError(description) from exc
def issue(self, payload: Mapping[str, Any]) -> str:
return self._passport.issue(dict(payload))

View File

@ -0,0 +1,185 @@
"""Application service for issuing passports used by deployed web applications."""
from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from enum import StrEnum
from typing import Any, Protocol
from pydantic import ValidationError
from services.entities.passport_entities import (
EndUserRecord,
WebAppLoginClaims,
WebAppRecord,
WebPassportEndUserResolution,
WebPassportRequest,
WebPassportResult,
)
class WebAppAuthType(StrEnum):
PUBLIC = "public"
INTERNAL = "internal"
EXTERNAL = "external"
class WebPassportNotFoundError(Exception):
pass
class WebPassportUnauthorizedError(Exception):
pass
class WebPassportAuthenticationRequiredError(Exception):
pass
class WebPassportRepository(Protocol):
def get_active_web_app(self, app_code: str) -> WebAppRecord | None: ...
def is_web_app_active(self, app: WebAppRecord) -> bool: ...
def resolve_standard_end_user(self, app: WebAppRecord, session_id: str | None) -> WebPassportEndUserResolution: ...
def resolve_authenticated_end_user(
self,
app: WebAppRecord,
*,
end_user_id: str | None,
session_id: str | None,
) -> WebPassportEndUserResolution: ...
class WebPassportAuthGateway(Protocol):
def is_webapp_auth_enabled(self) -> bool: ...
def get_app_auth_type(self, app_id: str) -> WebAppAuthType: ...
class WebPassportTokenGateway(Protocol):
def verify(self, token: str) -> Mapping[str, Any]: ...
def issue(self, payload: Mapping[str, Any]) -> str: ...
class WebPassportService:
def __init__(
self,
*,
passports: WebPassportRepository,
auth: WebPassportAuthGateway,
tokens: WebPassportTokenGateway,
now: Callable[[], datetime],
access_token_expire_minutes: int,
) -> None:
self._passports = passports
self._auth = auth
self._tokens = tokens
self._now = now
self._access_token_expire_minutes = access_token_expire_minutes
def issue(self, request: WebPassportRequest) -> WebPassportResult:
app = self._passports.get_active_web_app(request.app_code)
if app is None:
raise WebPassportNotFoundError()
login_claims: WebAppLoginClaims | None = None
if self._auth.is_webapp_auth_enabled():
login_claims = self._decode_login_token(request.access_token)
auth_type = self._auth.get_app_auth_type(app.app_id)
if auth_type != WebAppAuthType.PUBLIC:
if login_claims is None:
raise WebPassportAuthenticationRequiredError("Web app authentication required.")
self._require_active_web_app(app)
return self._exchange_enterprise_token(app, login_claims, auth_type)
end_user = self._resolve_standard_user(app, request.user_session_id)
token = self._tokens.issue(
{
"iss": app.app_id,
"sub": "Web API Passport",
"app_id": app.app_id,
"app_code": app.app_code,
"end_user_id": end_user.id,
}
)
return WebPassportResult(access_token=token)
def _decode_login_token(self, token: str | None) -> WebAppLoginClaims | None:
if not token:
return None
decoded = self._tokens.verify(token)
try:
claims = WebAppLoginClaims.model_validate(decoded)
except ValidationError as exc:
raise WebPassportUnauthorizedError("Invalid web app login token.") from exc
if claims.token_source != "webapp_login_token":
raise WebPassportUnauthorizedError("Invalid token source. Expected 'webapp_login_token'.")
return claims
def _resolve_standard_user(self, app: WebAppRecord, session_id: str | None) -> EndUserRecord:
resolution = self._passports.resolve_standard_end_user(app, session_id)
self._require_active_resolution(resolution)
if resolution.end_user is None:
raise WebPassportNotFoundError()
return resolution.end_user
def _exchange_enterprise_token(
self,
app: WebAppRecord,
claims: WebAppLoginClaims,
auth_type: WebAppAuthType,
) -> WebPassportResult:
user_auth_type = claims.auth_type
if not user_auth_type:
raise WebPassportUnauthorizedError("Missing auth_type in the token.")
if auth_type == WebAppAuthType.EXTERNAL and user_auth_type != WebAppAuthType.EXTERNAL:
raise WebPassportAuthenticationRequiredError("Please login as external user.")
if auth_type == WebAppAuthType.INTERNAL and user_auth_type != WebAppAuthType.INTERNAL:
raise WebPassportAuthenticationRequiredError("Please login as internal user.")
resolution = self._passports.resolve_authenticated_end_user(
app,
end_user_id=claims.end_user_id,
session_id=claims.session_id,
)
self._require_active_resolution(resolution)
if resolution.end_user is None:
if not claims.session_id:
raise WebPassportNotFoundError("Missing session_id for existing web user.")
raise WebPassportNotFoundError()
end_user = resolution.end_user
now = self._now()
expires_at = int((now + timedelta(minutes=self._access_token_expire_minutes)).timestamp())
if claims.exp:
expires_at = int(claims.exp)
token = self._tokens.issue(
{
"iss": app.site_id,
"sub": "Web API Passport",
"app_id": app.app_id,
"app_code": app.app_code,
"user_id": claims.user_id,
"end_user_id": end_user.id,
"auth_type": user_auth_type,
"granted_at": int(now.timestamp()),
"token_source": "webapp",
"exp": expires_at,
}
)
return WebPassportResult(access_token=token)
def _require_active_web_app(self, app: WebAppRecord) -> None:
if not self._passports.is_web_app_active(app):
raise WebPassportNotFoundError()
@staticmethod
def _require_active_resolution(resolution: WebPassportEndUserResolution) -> None:
if not resolution.app_active:
raise WebPassportNotFoundError()

View File

@ -1,4 +1,3 @@
import enum
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
@ -16,19 +15,11 @@ from models.enums import EndUserType
from models.model import App, EndUser, Site
from services.account_service import AccountService
from services.app_service import AppService
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService, WebAppAccessMode
from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, EnterpriseService
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
from tasks.mail_email_code_login import send_email_code_login_mail_task
class WebAppAuthType(enum.StrEnum):
"""Enum for web app authentication types."""
PUBLIC = "public"
INTERNAL = "internal"
EXTERNAL = "external"
class WebAppAuthService:
"""Service for web app authentication."""
@ -156,28 +147,3 @@ class WebAppAuthService:
if webapp_settings and webapp_settings.access_mode in PERMISSION_CHECK_MODES:
return True
return False
@classmethod
def get_app_auth_type(
cls, app_code: str | None = None, access_mode: str | None = None, *, session: Session
) -> WebAppAuthType:
"""
Get the authentication type for the app based on its access mode.
"""
if not app_code and not access_mode:
raise ValueError("Either app_code or access_mode must be provided.")
if access_mode:
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
elif access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
elif access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
if app_code:
app_id = AppService.get_app_id_by_code(app_code, session=session)
webapp_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
return cls.get_app_auth_type(access_mode=webapp_settings.access_mode, session=session)
raise ValueError("Could not determine app authentication type.")

View File

@ -94,6 +94,7 @@ from services.errors.app import (
WorkflowHashNotEqualError,
WorkflowNotFoundError,
)
from services.system_feature_service import SystemFeatureService
from tasks.new_agent_beta_task import register_new_agent_beta_workflow_publish_after_commit
@ -700,9 +701,7 @@ class WorkflowService:
)
# Validate credentials before publishing, for credential policy check
from services.feature_service import FeatureService
if FeatureService.is_plugin_manager_enabled():
if SystemFeatureService.is_plugin_manager_enabled():
self._validate_workflow_credentials(draft_workflow, session=session)
# validate graph structure

View File

@ -13,6 +13,7 @@ from configs import dify_config
from configs.feature import TemplateMode
from extensions.ext_mail import mail
from libs.email_i18n import get_email_i18n_service
from services.entities.mail_entities import InnerMailMessage
logger = logging.getLogger(__name__)
@ -29,7 +30,7 @@ class SandboxedEnvironment(ImmutableSandboxedEnvironment):
return super().call(context, obj, *args, **kwargs)
def _render_template_with_strategy(body: str, substitutions: Mapping[str, str]) -> str:
def _render_template_with_strategy(body: str, substitutions: Mapping[str, Any]) -> str:
mode = dify_config.MAIL_TEMPLATING_MODE
timeout = dify_config.MAIL_TEMPLATING_TIMEOUT
if mode == TemplateMode.UNSAFE:
@ -43,7 +44,7 @@ def _render_template_with_strategy(body: str, substitutions: Mapping[str, str])
@shared_task(queue="mail")
def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, str]):
def send_inner_email_task(to: list[str], subject: str, body: str, substitutions: Mapping[str, Any]):
if not mail.is_inited():
return
@ -60,3 +61,12 @@ def send_inner_email_task(to: list[str], subject: str, body: str, substitutions:
logger.info(click.style(f"Send enterprise mail to {to} succeeded: latency: {end_at - start_at}", fg="green"))
except Exception:
logger.exception("Send enterprise mail to %s failed", to)
def enqueue_inner_mail(message: InnerMailMessage) -> None:
send_inner_email_task.delay(
to=list(message.recipients),
subject=message.subject,
body=message.body,
substitutions=message.substitutions or {},
)

View File

@ -21,11 +21,11 @@ from tests.test_containers_integration_tests.helpers import generate_valid_passw
@pytest.fixture
def setup_dependencies() -> Iterator[MagicMock]:
with (
patch("services.account_service.FeatureService") as feature_service,
patch("services.account_service.SystemFeatureService") as feature_service,
patch("services.account_service.BillingService") as billing_service,
patch("services.account_service.CommunityTelemetryService.report_install") as report_install,
):
feature_service.get_system_features.return_value.is_allow_register = True
feature_service.is_registration_allowed.return_value = True
feature_service.get_license.return_value.seats.is_available.return_value = True
feature_service.get_license.return_value.workspaces.is_available.return_value = True
feature_service.is_workspace_creation_allowed.return_value = True

View File

@ -38,8 +38,8 @@ def make_account(db_session_with_containers: Session) -> Callable[..., Account]:
def _make(*, with_owner_tenant: bool = True) -> Account:
fake = Faker()
with patch("services.account_service.FeatureService") as mock_feature_service:
mock_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_feature_service:
mock_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
@ -60,7 +60,7 @@ def add_tenant_for_account(
account: Account, *, session: Session, role: str = "normal", name: str = "Second WS"
) -> Tenant:
"""Create an additional tenant and join ``account`` to it (real service calls)."""
with patch("services.account_service.FeatureService") as mock_feature_service:
with patch("services.account_service.SystemFeatureService") as mock_feature_service:
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)

View File

@ -51,7 +51,7 @@ def external_deps() -> Generator[dict[str, object], None, None]:
patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service,
patch("services.app_dsl_service.app_was_created") as mock_app_was_created,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
):
mock_workflow_service.return_value.get_draft_workflow.return_value = None
@ -65,7 +65,7 @@ def external_deps() -> Generator[dict[str, object], None, None]:
mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
@ -74,8 +74,8 @@ def external_deps() -> Generator[dict[str, object], None, None]:
def _app_and_account(db_session: Session, *, mode: str = "chat") -> tuple[App, Account]:
fake = Faker()
with patch("services.account_service.FeatureService") as mock_account_feature_service:
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_account_feature_service:
mock_account_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),

View File

@ -231,7 +231,7 @@ class TestDecodeJwtToken:
@patch("controllers.web.wraps._validate_webapp_token")
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.get_app_access_mode_by_id")
@patch("controllers.web.wraps.AppService.get_app_id_by_code")
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_happy_path(
@ -254,7 +254,7 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
result_app, result_user = decode_jwt_token()
@ -262,17 +262,17 @@ class TestDecodeJwtToken:
assert result_app.id == app_model.id
assert result_user.id == end_user.id
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_token_raises_unauthorized(self, mock_extract: MagicMock, mock_features: MagicMock, app) -> None:
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
mock_extract.return_value = None
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(Unauthorized):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_app_raises_not_found(
@ -289,13 +289,13 @@ class TestDecodeJwtToken:
"app_id": non_existent_id,
"end_user_id": str(uuid4()),
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": "code1"}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_disabled_site_raises_bad_request(
@ -314,13 +314,13 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(BadRequest, match="Site is disabled"):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_missing_end_user_raises_not_found(
@ -340,13 +340,13 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": non_existent_eu,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(NotFound):
decode_jwt_token()
@patch("controllers.web.wraps.FeatureService.get_system_features")
@patch("controllers.web.wraps.SystemFeatureService.is_webapp_auth_enabled")
@patch("controllers.web.wraps.PassportService")
@patch("controllers.web.wraps.extract_webapp_passport")
def test_user_id_mismatch_raises_unauthorized(
@ -365,7 +365,7 @@ class TestDecodeJwtToken:
"app_id": app_model.id,
"end_user_id": end_user.id,
}
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_features.return_value = False
with app.test_request_context("/", headers={"X-App-Code": site.code}):
with pytest.raises(Unauthorized, match="expired"):

View File

@ -626,10 +626,10 @@ class TestKnowledgeRetrievalIntegration:
@pytest.fixture
def mock_external_service_dependencies():
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -49,7 +49,7 @@ class TestDatasetDocumentProperties:
db_session_with_containers.add(doc)
db_session_with_containers.flush()
assert dataset.total_documents == 3
assert dataset.get_total_documents(session=db_session_with_containers) == 3
def test_dataset_available_documents_count(self, db_session_with_containers: Session) -> None:
"""Test dataset can count available documents."""
@ -104,7 +104,7 @@ class TestDatasetDocumentProperties:
db_session_with_containers.add_all([doc_available, doc_pending, doc_disabled])
db_session_with_containers.flush()
assert dataset.total_available_documents == 1
assert dataset.get_total_available_documents(session=db_session_with_containers) == 1
def test_dataset_word_count_aggregation(self, db_session_with_containers: Session) -> None:
"""Test dataset can aggregate word count from documents."""
@ -426,7 +426,7 @@ class TestDocumentSegmentNavigationProperties:
db_session_with_containers.flush()
# Act
prev_seg = segment.previous_segment
prev_seg = segment.previous_segment(session=db_session_with_containers)
# Assert
assert prev_seg is not None
@ -483,7 +483,7 @@ class TestDocumentSegmentNavigationProperties:
db_session_with_containers.flush()
# Act
next_seg = segment.next_segment
next_seg = segment.next_segment(session=db_session_with_containers)
# Assert
assert next_seg is not None

View File

@ -30,12 +30,12 @@ class TestAccountService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
patch("services.account_service.PassportService") as mock_passport_service,
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_registration_allowed.return_value = 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
@ -57,7 +57,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account(
@ -84,7 +84,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account(
@ -108,7 +108,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Test with too short password (assuming minimum length validation)
@ -131,7 +131,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks to disable registration
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = False
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = False
with pytest.raises(AccountNotFound): # AccountNotFound exception
AccountService.create_account(
@ -153,7 +153,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True
dify_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
@ -189,7 +189,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account first
@ -219,7 +219,7 @@ class TestAccountService:
correct_password = generate_valid_password(fake)
wrong_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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account first
@ -245,7 +245,7 @@ class TestAccountService:
name = fake.name()
new_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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account without password
@ -280,7 +280,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account with pending status
@ -309,7 +309,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -342,7 +342,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
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
@ -366,7 +366,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -393,7 +393,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.seats.is_available.return_value = False
@ -418,7 +418,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -456,7 +456,7 @@ class TestAccountService:
email = fake.email()
name = fake.name()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -498,7 +498,7 @@ class TestAccountService:
password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -529,7 +529,7 @@ class TestAccountService:
password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -568,7 +568,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -599,7 +599,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -634,7 +634,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "new_mock_access_token"
@ -683,7 +683,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_access_token"
@ -718,7 +718,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -759,7 +759,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -788,7 +788,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
mock_external_service_dependencies["passport_service"].return_value.issue.return_value = "mock_jwt_token"
@ -824,7 +824,7 @@ class TestAccountService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -859,7 +859,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -900,7 +900,7 @@ class TestAccountService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -946,7 +946,7 @@ class TestTenantService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
):
# Setup default mock returns
@ -2038,12 +2038,12 @@ class TestRegisterService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_feature_service,
patch("services.account_service.SystemFeatureService") as mock_feature_service,
patch("services.account_service.BillingService") as mock_billing_service,
patch("services.account_service.PassportService") as mock_passport_service,
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_registration_allowed.return_value = 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
@ -2066,7 +2066,7 @@ class TestRegisterService:
admin_password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
from models.model import DifySetup
@ -2115,7 +2115,7 @@ class TestRegisterService:
admin_password = generate_valid_password(fake)
ip_address = fake.ipv4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Mock AccountService.create_account to raise exception
@ -2157,7 +2157,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2199,7 +2199,7 @@ class TestRegisterService:
provider = fake.random_element(elements=("google", "github", "microsoft"))
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2246,7 +2246,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2290,7 +2290,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
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
@ -2327,7 +2327,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2365,7 +2365,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration without workspace creation
@ -2404,7 +2404,7 @@ class TestRegisterService:
new_member_email = fake.email()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
@ -2476,7 +2476,7 @@ class TestRegisterService:
existing_member_password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2550,7 +2550,7 @@ class TestRegisterService:
existing_pending_member_password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2611,7 +2611,7 @@ class TestRegisterService:
new_member_email = fake.email()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant
@ -2644,7 +2644,7 @@ class TestRegisterService:
already_in_tenant_password = generate_valid_password(fake)
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@ -2696,7 +2696,7 @@ class TestRegisterService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2741,7 +2741,7 @@ class TestRegisterService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2789,7 +2789,7 @@ class TestRegisterService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2833,7 +2833,7 @@ class TestRegisterService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2877,7 +2877,7 @@ class TestRegisterService:
name = fake.name()
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"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -2950,7 +2950,7 @@ class TestRegisterService:
invalid_tenant_id = fake.uuid4()
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create account
@ -3002,7 +3002,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account
@ -3054,7 +3054,7 @@ class TestRegisterService:
password = generate_valid_password(fake)
token = fake.uuid4()
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and account

View File

@ -26,10 +26,10 @@ class TestAgentService:
patch("services.agent_service.ToolManager", autospec=True) as mock_tool_manager,
patch("services.agent_service.AgentConfigManager", autospec=True) as mock_agent_config_manager,
patch("services.agent_service.current_user", create_autospec(Account, instance=True)) as mock_current_user,
patch("services.app_service.FeatureService", autospec=True) as mock_feature_service,
patch("services.app_service.SystemFeatureService", autospec=True) as mock_feature_service,
patch("services.app_service.EnterpriseService", autospec=True) as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant", autospec=True) as mock_model_manager,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service,
):
# Setup default mock returns for agent service
mock_plugin_agent_client_instance = mock_plugin_agent_client.return_value
@ -67,12 +67,12 @@ class TestAgentService:
mock_current_user.timezone = "UTC"
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -104,9 +104,7 @@ class TestAgentService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -21,7 +21,7 @@ class TestAnnotationService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.annotation_service.FeatureService") as mock_feature_service,
patch("services.annotation_service.add_annotation_to_index_task") as mock_add_task,
patch("services.annotation_service.update_annotation_to_index_task") as mock_update_task,
@ -70,9 +70,7 @@ class TestAnnotationService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -17,7 +17,7 @@ class TestAPIBasedExtensionService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.api_based_extension_service.APIBasedExtensionRequestor") as mock_requestor,
):
# Setup default mock returns
@ -47,9 +47,7 @@ class TestAPIBasedExtensionService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -122,7 +122,7 @@ class TestAppDslService:
patch("services.app_dsl_service.DependenciesAnalysisService") as mock_dependencies_service,
patch("services.app_dsl_service.app_was_created") as mock_app_was_created,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
):
mock_workflow_service.return_value.get_draft_workflow.return_value = None
@ -139,7 +139,7 @@ class TestAppDslService:
"gpt-3.5-turbo",
)
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
yield {
@ -153,8 +153,8 @@ class TestAppDslService:
def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
fake = Faker()
with patch("services.account_service.FeatureService") as mock_account_feature_service:
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
with patch("services.account_service.SystemFeatureService") as mock_account_feature_service:
mock_account_feature_service.is_registration_allowed.return_value = True
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),

View File

@ -38,7 +38,7 @@ class TestAppGenerateService:
patch(
"services.app_generate_service.MessageBasedAppGenerator", autospec=True
) as mock_message_based_generator,
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
patch("services.account_service.SystemFeatureService", autospec=True) as mock_account_feature_service,
patch("services.app_generate_service.dify_config") as mock_dify_config,
patch("services.quota_service.dify_config") as mock_quota_dify_config,
patch("configs.dify_config") as mock_global_dify_config,
@ -104,7 +104,7 @@ class TestAppGenerateService:
mock_message_based_generator.retrieve_events.return_value = ["workflow_events"]
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Setup dify_config mock returns
mock_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
@ -155,9 +155,7 @@ class TestAppGenerateService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
from services.account_service import AccountService, TenantService

View File

@ -25,18 +25,18 @@ class TestAppService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -1252,9 +1252,7 @@ class TestAppService:
app_id = app.id
# Mock webapp auth cleanup
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.webapp_auth.enabled = True
mock_external_service_dependencies["feature_service"].is_webapp_auth_enabled.return_value = True
# Mock the async deletion task
with patch("services.app_service.remove_app_and_related_data_task") as mock_delete_task:

View File

@ -14,6 +14,7 @@ from services.entities.feature_entities import (
SystemFeatureModel,
)
from services.feature_service import FeatureService
from services.system_feature_service import SystemFeatureService
class TestFeatureService:
@ -25,6 +26,7 @@ class TestFeatureService:
with (
patch("services.feature_service.BillingService") as mock_billing_service,
patch("services.feature_service.EnterpriseService") as mock_enterprise_service,
patch("services.system_feature_service.EnterpriseService", new=mock_enterprise_service),
):
# Setup default mock returns for BillingService
mock_billing_service.get_info.return_value = {
@ -273,7 +275,7 @@ class TestFeatureService:
# Arrange: Setup test data with proper config
tenant_id = self._create_test_tenant_id()
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -285,7 +287,7 @@ class TestFeatureService:
mock_config.MAIL_TYPE = "smtp"
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -345,7 +347,7 @@ class TestFeatureService:
- The response structure adheres to the public schema for unauthenticated clients.
"""
# Arrange: Setup test data with exact same config as success test
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -357,7 +359,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the public (unauthenticated) system-features call
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Basic structure
assert result is not None
@ -399,11 +401,11 @@ class TestFeatureService:
- Detail withheld from the public system-features model is present here.
"""
# Arrange
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
# Act
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: full license detail is populated
assert isinstance(result, LicenseModel)
@ -418,10 +420,10 @@ class TestFeatureService:
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Non-enterprise deployments have no license, so limits are unconstrained."""
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.NONE
@ -442,7 +444,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup basic config mock (no enterprise)
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@ -456,7 +458,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -640,7 +642,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Use the Community edition.
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -653,7 +655,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 50
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -863,7 +865,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case webapp auth mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -879,7 +881,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -981,7 +983,7 @@ class TestFeatureService:
"""
# Test case 1: Official only scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -999,12 +1001,12 @@ class TestFeatureService:
}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "official_only"
assert result.plugin_installation_permission.restrict_to_marketplace_only is True
# Test case 2: All plugins scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1019,12 +1021,12 @@ class TestFeatureService:
"PluginInstallationPermission": {"pluginInstallationScope": "all", "restrictToMarketplaceOnly": False}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "all"
assert result.plugin_installation_permission.restrict_to_marketplace_only is False
# Test case 3: Specific partners scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1042,12 +1044,12 @@ class TestFeatureService:
}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "official_and_specific_partners"
assert result.plugin_installation_permission.restrict_to_marketplace_only is False
# Test case 4: None scope
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1062,7 +1064,7 @@ class TestFeatureService:
"PluginInstallationPermission": {"pluginInstallationScope": "none", "restrictToMarketplaceOnly": True}
}
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
assert result.plugin_installation_permission.plugin_installation_scope == "none"
assert result.plugin_installation_permission.restrict_to_marketplace_only is True
@ -1120,7 +1122,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup inactive license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1140,7 +1142,7 @@ class TestFeatureService:
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
@ -1169,7 +1171,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup partial enterprise info mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1187,7 +1189,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1297,7 +1299,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case protocols mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1315,7 +1317,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1448,7 +1450,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup expired license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1468,7 +1470,7 @@ class TestFeatureService:
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
result = SystemFeatureService.get_license()
# Assert: Verify the expected outcomes
assert result is not None
@ -1554,7 +1556,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup edge case branding mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1575,7 +1577,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None
@ -1740,7 +1742,7 @@ class TestFeatureService:
- Return value correctness and structure
"""
# Arrange: Setup lost license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
with patch("services.system_feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@ -1756,7 +1758,7 @@ class TestFeatureService:
}
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = SystemFeatureService.get_public_system_features()
# Assert: Verify the expected outcomes
assert result is not None

View File

@ -24,7 +24,7 @@ class TestMessageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.message_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.message_service.WorkflowService") as mock_workflow_service,
patch("services.message_service.AdvancedChatAppConfigManager") as mock_app_config_manager,
@ -86,9 +86,7 @@ class TestMessageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -20,15 +20,15 @@ class TestOpsService:
@pytest.fixture
def mock_external_service_dependencies(self):
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
mock_model_instance = mock_model_manager.return_value
mock_model_instance.get_default_model_instance.return_value = None
mock_model_instance.get_default_provider_model_name.return_value = ("openai", "gpt-3.5-turbo")

View File

@ -20,12 +20,12 @@ class TestSavedMessageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.saved_message_service.MessageService") as mock_message_service,
):
# Setup default mock returns
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for app creation
mock_model_instance = mock_model_manager.return_value
@ -56,9 +56,7 @@ class TestSavedMessageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant first
from services.account_service import AccountService, TenantService

View File

@ -27,7 +27,7 @@ class TestTriggerProviderService:
patch("services.trigger.trigger_provider_service.TriggerManager") as mock_trigger_manager,
patch("services.trigger.trigger_provider_service.redis_client") as mock_redis_client,
patch("services.trigger.trigger_provider_service.delete_cache_for_subscription") as mock_delete_cache,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns
mock_provider_controller = MagicMock()
@ -42,7 +42,7 @@ class TestTriggerProviderService:
mock_redis_client.lock.return_value = mock_lock
# Setup account feature service mock
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"trigger_manager": mock_trigger_manager,
@ -71,9 +71,7 @@ class TestTriggerProviderService:
from services.account_service import AccountService, TenantService
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
mock_external_service_dependencies[
"trigger_manager"
].get_trigger_provider.return_value = mock_external_service_dependencies["provider_controller"]

View File

@ -23,18 +23,18 @@ class TestWebConversationService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -62,9 +62,7 @@ class TestWebConversationService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -11,7 +11,7 @@ from libs.password import hash_password
from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.model import App, Site
from services.errors.account import AccountLoginError, AccountNotFoundError, AccountPasswordError
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
from services.webapp_auth_service import WebAppAuthService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@ -825,90 +825,3 @@ class TestWebAppAuthService:
WebAppAuthService.is_app_require_permission_check(session=db_session_with_containers)
assert "Either app_code or app_id must be provided." in str(exc_info.value)
def test_get_app_auth_type_with_access_mode_public(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type for public access mode.
This test verifies:
- Proper authentication type determination for public mode
- Correct return value
- Mock service integration
"""
# Arrange: Setup test with public access mode
# Act: Execute authentication type determination
result = WebAppAuthService.get_app_auth_type(access_mode="public", session=db_session_with_containers)
# Assert: Verify correct result
assert result == WebAppAuthType.PUBLIC
def test_get_app_auth_type_with_access_mode_private(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type for private access mode.
This test verifies:
- Proper authentication type determination for private mode
- Correct return value
- Mock service integration
"""
# Arrange: Setup test with private access mode
# Act: Execute authentication type determination
result = WebAppAuthService.get_app_auth_type(access_mode="private", session=db_session_with_containers)
# Assert: Verify correct result
assert result == WebAppAuthType.INTERNAL
def test_get_app_auth_type_with_app_code(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type using app code.
This test verifies:
- Proper authentication type determination using app code
- Correct return value
- Mock service integration
"""
# Arrange: Setup mock for enterprise service
mock_external_service_dependencies["app_service"].get_app_id_by_code.return_value = "mock_app_id"
setting = type("MockWebAppAuth", (), {"access_mode": "sso_verified"})()
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_id.return_value = setting
# Act: Execute authentication type determination
result: WebAppAuthType = WebAppAuthService.get_app_auth_type(
app_code="mock_app_code", session=db_session_with_containers
)
# Assert: Verify correct result
assert result == WebAppAuthType.EXTERNAL
# Verify mock service was called correctly
mock_external_service_dependencies[
"enterprise_service"
].WebAppAuth.get_app_access_mode_by_id.assert_called_once_with(app_id="mock_app_id")
def test_get_app_auth_type_no_parameters(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test app authentication type with no parameters.
This test verifies:
- Proper error handling when no parameters provided
- Correct exception type and message
"""
# Arrange: No parameters provided
# Act & Assert: Verify proper error handling
with pytest.raises(ValueError) as exc_info:
WebAppAuthService.get_app_auth_type(session=db_session_with_containers)
assert "Either app_code or access_mode must be provided." in str(exc_info.value)

View File

@ -8,14 +8,12 @@ from faker import Faker
from flask import Flask
from sqlalchemy.orm import Session
from enums import DeploymentEdition
from models.account import Account, Tenant
from models.enums import AppTriggerStatus, AppTriggerType
from models.model import App
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from models.workflow import Workflow
from services.account_service import AccountService, TenantService
from services.entities.feature_entities import SystemFeatureModel
from services.trigger.webhook_service import WebhookService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@ -38,16 +36,12 @@ def test_data(
"""Persist the webhook graph with account and workspace creation enabled."""
fake = Faker()
system_features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
is_allow_register=True,
monkeypatch.setattr(
"services.account_service.SystemFeatureService.is_registration_allowed",
lambda: True,
)
monkeypatch.setattr(
"services.account_service.FeatureService.get_system_features",
lambda: system_features,
)
monkeypatch.setattr(
"services.account_service.FeatureService.is_workspace_creation_allowed",
"services.account_service.SystemFeatureService.is_workspace_creation_allowed",
lambda: True,
)
account = AccountService.create_account(

View File

@ -28,18 +28,18 @@ class TestWorkflowAppService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -67,9 +67,7 @@ class TestWorkflowAppService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(
@ -116,9 +114,7 @@ class TestWorkflowAppService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -25,18 +25,18 @@ class TestWorkflowRunService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -64,9 +64,7 @@ class TestWorkflowRunService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -23,10 +23,10 @@ class TestWorkflowToolManageService:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.app_service.FeatureService") as mock_feature_service,
patch("services.app_service.SystemFeatureService") as mock_feature_service,
patch("services.app_service.EnterpriseService") as mock_enterprise_service,
patch("services.app_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
patch(
"services.tools.workflow_tools_manage_service.WorkflowToolProviderController"
) as mock_workflow_tool_provider_controller,
@ -34,12 +34,12 @@ class TestWorkflowToolManageService:
patch("services.tools.workflow_tools_manage_service.ToolTransformService") as mock_tool_transform_service,
):
# Setup default mock returns for app service
mock_feature_service.get_system_features.return_value.webapp_auth.enabled = False
mock_feature_service.is_webapp_auth_enabled.return_value = False
mock_enterprise_service.WebAppAuth.update_app_access_mode.return_value = None
mock_enterprise_service.WebAppAuth.cleanup_webapp.return_value = None
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
# Mock ModelManager for model configuration
mock_model_instance = mock_model_manager.return_value
@ -79,9 +79,7 @@ class TestWorkflowToolManageService:
fake = Faker()
# Setup mocks for account creation
mock_external_service_dependencies[
"account_feature_service"
].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["account_feature_service"].is_registration_allowed.return_value = True
# Create account and tenant
account = AccountService.create_account(

View File

@ -37,10 +37,10 @@ class TestCleanNotionDocumentTask:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -29,10 +29,10 @@ class TestDealDatasetVectorIndexTask:
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.account_service.SystemFeatureService") as mock_account_feature_service,
):
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
mock_account_feature_service.is_registration_allowed.return_value = True
yield {
"account_feature_service": mock_account_feature_service,

View File

@ -41,7 +41,7 @@ from models.trigger import (
from models.workflow import Workflow
from schedule import workflow_schedule_task
from schedule.workflow_schedule_task import poll_workflow_schedules
from services import feature_service as feature_service_module
from services.system_feature_service import SystemFeatureService
from services.trigger import webhook_service
from services.trigger.schedule_service import ScheduleService
from services.workflow_service import WorkflowService
@ -112,7 +112,7 @@ def test_publish_blocks_start_and_trigger_coexistence(
workflow_service = WorkflowService()
monkeypatch.setattr(
feature_service_module.FeatureService,
SystemFeatureService,
"is_plugin_manager_enabled",
classmethod(lambda _cls: False),
)

Some files were not shown because too many files have changed in this diff Show More