User and Tenant Context Propagation#
User identity and tenant context travel through two parallel mechanisms in Dify's workflow execution stack:
- Celery task serialization β user identity is serialized as primitive IDs into
AppExecutionParamsand re-hydrated from the database in the worker process. FileAccessScopeContextVar β a frozen dataclass bound per-execution that gates allUploadFile/ToolFiledatabase lookups to the correct tenant and user.
A PR #39283 refactoring (mid-2026) eliminated the old user.set_tenant_id() mutation pattern; all repository factory calls now require tenant_id as an explicit parameter.
Scope note: This article covers the serialization/deserialization boundary at the Celery worker and the file-access ContextVar. For
DifyRunContextthreading through the workflow graph and child engines, see the Multi-Tenant Context Propagation knowledge base article.
Celery Task Serialization#
The entry point for async workflow execution is workflow_based_app_execution_task, a Celery shared task that accepts a JSON payload string and no live ORM objects.
AppExecutionParams is the Pydantic model serialized into that payload. Key fields:
| Field | Type | Notes |
|---|---|---|
tenant_id | str | Explicit, from app_model.tenant_id |
user | _Account | _EndUser | Discriminated union β only carries user_id or end_user_id, never ORM state |
app_id, workflow_id, app_mode | str | Identity of the target app/workflow |
invoke_from | InvokeFrom | Entry point (DEBUGGER, SERVICE_API, WEB_APP, etc.) |
workflow_run_id | str | Pre-allocated UUID for the run |
The factory AppExecutionParams.new() converts live Account / EndUser ORM objects into these serializable ID-only models before dispatch. Nothing mutable crosses the process boundary.
Worker reconstruction β _AppRunner._resolve_user() re-fetches the Account or EndUser from the database within the worker using the stored ID. After PR #39283, this no longer calls user.set_tenant_id() β the tenant is passed explicitly to repositories instead. Flask context is then restored via _setup_flask_context β set_login_user(user) , which sets g._login_user for any code that relies on Flask-Login's current_user.
ContextVar Propagation into Worker Threads#
Within a single process (non-Celery path), AdvancedChatAppGenerator._generate() spawns a worker thread for the actual graph execution. Propagating the FileAccessScope ContextVar across this thread boundary requires an explicit snapshot:
contextvars.copy_context()is called on the main thread, capturing all current ContextVar values (including_current_file_access_scope).- The resulting
contextobject is passed to the worker thread as a kwarg . - Inside
_generate_worker,preserve_flask_contexts(flask_app, context_vars=context)iteratescontext.items()and calls.set()on each ContextVar, replaying the snapshot into the new thread .
preserve_flask_contexts explicitly notes this cross-thread pattern is "not recommended" and that passing the user directly as parameters is preferred .
Celery workers do not share a thread with the caller β the FileAccessScope ContextVar is None by default in a fresh worker process. In this path, _AppRunner.run() calls _setup_flask_context to reconstruct Flask g, but the FileAccessScope ContextVar must be re-bound by the generator method (e.g., AdvancedChatAppGenerator.generate() calls self._bind_file_access_scope(...) as a context manager wrapping the file-parsing and generation steps) .
FileAccessScope β Per-Execution File Authorization#
FileAccessScope is a frozen=True dataclass stored in the module-level ContextVar _current_file_access_scope. It is the single source of truth for file lookup authorization during an execution.
Fields:
| Field | Purpose |
|---|---|
tenant_id | Mandatory outer boundary β always applied to SQL queries |
user_id | Invoking user's ID |
user_from | ACCOUNT or END_USER |
invoke_from | Entry point |
granted_upload_file_ids | Execution-local allowlist (frozenset) for trusted retrieval files |
granted_tool_file_ids | Execution-local allowlist (frozenset) for ToolFile records produced during this run |
granted_retriever_segment_ids | Execution-local allowlist for knowledge segment attachment access |
The requires_user_ownership property returns True when user_from == END_USER, triggering per-user SQL filters in DatabaseFileAccessController.
Binding: BaseAppGenerator._bind_file_access_scope() constructs the initial scope from tenant_id, user (discriminating Account vs. EndUser), and invoke_from, then returns the bind_file_access_scope() context manager. If user.id is absent or empty, a nullcontext() is returned β no scope is set . All app types (completion, chat, agent, advanced chat, workflow) call this base method.
SQL enforcement: DatabaseFileAccessController.apply_upload_file_filters() applies three ordered conditions: (1) UploadFile.tenant_id == scope.tenant_id always; (2) created_by_role == END_USER AND created_by == user_id when requires_user_ownership; (3) OR-expanded with UploadFile.id IN (granted_ids) when there are execution-local grants. apply_tool_file_filters() applies the same pattern: tenant scoping is mandatory, user ownership is checked when requires_user_ownership is true, and files in granted_tool_file_ids are allowed even when ToolFile.user_id differs from the scope's user_id.
Mutations stay immutable: Grant functions (grant_upload_file_access, grant_tool_file_access, grant_retriever_segment_access) produce a new scope via dataclasses.replace() and call ContextVar.set() β the frozen dataclass is never mutated in place .
Tool file granting in workflow-as-tool scenarios: When tool or plugin nodes produce files (e.g., image downloads), those files are stored as ToolFile records. The user_id on a ToolFile often differs from the chatting end user's ID in nested workflow scenarios (a published workflow invoked as a tool from an agent). To ensure later LLM nodes in the same execution can access these files, ToolFileMessageTransformer._with_tool_file_meta() calls grant_tool_file_access() when transforming tool output messages. This accumulates produced tool_file_ids in the current FileAccessScope, allowing downstream nodes to attach the files even when ownership does not match [#41169].
Refactoring Away from Mutable Account State (PR #39283)#
Before: Celery worker code called user.set_tenant_id(tenant_id) on the re-fetched Account object before creating repositories. This was thread-unsafe, did not survive serialization, and created an implicit dependency on mutable ORM state.
After (PR #39283):
DifyCoreRepositoryFactory.create_workflow_execution_repository()andcreate_workflow_node_execution_repository()now require an explicittenant_id: strparameter. All callers passtenant_id=app_model.tenant_idat construction time._AppRunner._resolve_user()returns the rawAccount/EndUserwithout anyset_tenant_id()call.- Both resume paths (
_resume_advanced_chat,_resume_workflow) inworkflow_execute_task.pypass explicittenant_id. FileService.upload_file()gained an optionaltenant_idparameter; callers can override tenant extraction from the user object, with fallback toextract_tenant_id(user)if omitted.- Tests assert
set_tenant_idis never called:test_app_runner_resolves_account_without_switching_tenantandtest_resolve_account_for_run_without_switching_tenantuseassert_not_called().
The pattern now matches what AppExecutionParams already did: tenant_id is a first-class string field extracted from the app model, never derived from mutable user state.
Key Files and References#
| File | Role |
|---|---|
api/tasks/app_generate/workflow_execute_task.py | AppExecutionParams, _AppRunner, workflow_based_app_execution_task, resume tasks |
api/core/app/file_access/scope.py | FileAccessScope, _current_file_access_scope ContextVar, grant functions, bind_file_access_scope |
api/core/app/file_access/controller.py | DatabaseFileAccessController β SQL filter application |
api/core/app/apps/base_app_generator.py | _bind_file_access_scope() β scope construction and binding at execution start |
api/core/app/apps/advanced_chat/app_generator.py | contextvars.copy_context() β worker thread; _generate_worker with preserve_flask_contexts |
api/libs/flask_utils.py | preserve_flask_contexts (ContextVar replay into worker thread), set_login_user |
Notable PRs:
| PR | Summary |
|---|---|
| #39283 | Removed user.set_tenant_id() mutations; explicit tenant_id added to all repository factory calls |
| #39149 | Scoped trial app file uploads to the app's owning tenant via explicit resource_tenant_id |
| #36175 / #36195 | Added execution-local grant mechanism for knowledge retrieval images failing end-user ownership checks |
| #32964 | Introduced DifyRunContext; consolidated user identity fields for workflow graph propagation |
| #41300 | Added granted_tool_file_ids grant field and automatic granting in ToolFileMessageTransformer to support workflow-as-tool file passing [#41169] |