From edc805c4530edfc2d0197ca59a9539f5b56457cc Mon Sep 17 00:00:00 2001 From: WH-2099 Date: Fri, 21 Aug 2026 00:52:39 +0000 Subject: [PATCH] fix(api): enforce app-scoped access on indirect resources (#40893) --- api/controllers/console/app/workflow.py | 20 ++- api/controllers/openapi/app_dsl.py | 34 +++-- api/services/app_dsl_service.py | 40 +++++- api/services/data_migration/import_service.py | 6 +- .../workflow_collaboration_service.py | 30 +++- api/services/workflow_service.py | 16 ++- .../controllers/console/app/test_workflow.py | 30 ++-- .../controllers/openapi/test_app_dsl.py | 49 +++++++ .../data_migration/test_import_service.py | 35 ++--- .../services/test_app_dsl_service.py | 129 +++++++++++++++++- .../test_workflow_collaboration_service.py | 75 ++++++++-- .../services/test_workflow_service.py | 20 +++ oxlint-suppressions.json | 8 -- .../__tests__/workflow-main.spec.tsx | 24 ++-- .../workflow-app/components/workflow-main.tsx | 32 ++--- .../hooks/__tests__/use-collaboration.spec.ts | 34 +++-- .../collaboration/hooks/use-collaboration.ts | 10 +- .../workflow/header/online-users.tsx | 74 ++++++---- .../_base/components/workflow-panel/index.tsx | 3 +- .../components/workflow/nodes/_base/node.tsx | 4 +- 20 files changed, 519 insertions(+), 154 deletions(-) create mode 100644 api/tests/unit_tests/controllers/openapi/test_app_dsl.py diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 4d90161f52d..4b9ff67c0e8 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -20,6 +20,8 @@ from sqlalchemy.orm import Session, sessionmaker from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services +from configs import dify_config +from controllers.common.app_access import resolve_app_access_filter from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload from controllers.common.errors import InvalidArgumentError from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse @@ -53,6 +55,7 @@ from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY from core.app.entities.app_invoke_entities import InvokeFrom from core.app.file_access import DatabaseFileAccessController +from core.db.session_factory import session_factory from core.helper import encrypter from core.helper.trace_id_helper import get_external_trace_id from core.plugin.impl.exc import PluginInvokeError @@ -1924,8 +1927,9 @@ class WorkflowOnlineUsersApi(Resource): @setup_required @login_required @account_initialization_required + @with_current_user @with_current_tenant_id - def post(self, current_tenant_id: str): + def post(self, current_tenant_id: str, current_user: Account): args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {}) app_ids = args.app_ids @@ -1935,8 +1939,20 @@ class WorkflowOnlineUsersApi(Resource): if not app_ids: return {"data": []} + access_filter = None workflow_service = WorkflowService() - accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id, session=db.session()) + with session_factory.create_session() as session: + if dify_config.RBAC_ENABLED: + access_filter = resolve_app_access_filter(current_tenant_id, current_user.id, session=session) + app_maintainers = workflow_service.get_tenant_app_maintainers(app_ids, current_tenant_id, session=session) + + accessible_app_ids = set(app_maintainers) + if access_filter is not None: + accessible_app_ids = { + app_id + for app_id, maintainer in app_maintainers.items() + if access_filter.is_app_accessible(app_id, maintainer, current_user.id) + } ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids] users_json_by_app_id: dict[str, Any] = {} diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py index d06845dada4..5d036580654 100644 --- a/api/controllers/openapi/app_dsl.py +++ b/api/controllers/openapi/app_dsl.py @@ -4,6 +4,7 @@ from typing import cast from flask_restx import Resource from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden from controllers.common.wraps import RBACPermission, RBACResourceScope from controllers.openapi import openapi_ns @@ -17,6 +18,7 @@ from models import Account, App from models.account import TenantAccountRole from services.app_dsl_service import AppDslService, Import from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus +from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError @@ -53,18 +55,21 @@ class AppDslImportApi(Resource): with Session(db.engine, expire_on_commit=False) as session: service = AppDslService(session) - result = service.import_app( - account=account, - import_mode=body.mode, - yaml_content=body.yaml_content, - yaml_url=body.yaml_url, - name=body.name, - description=body.description, - icon_type=body.icon_type, - icon=body.icon, - icon_background=body.icon_background, - app_id=body.app_id, - ) + try: + result = service.import_app( + account=account, + import_mode=body.mode, + yaml_content=body.yaml_content, + yaml_url=body.yaml_url, + name=body.name, + description=body.description, + icon_type=body.icon_type, + icon=body.icon, + icon_background=body.icon_background, + app_id=body.app_id, + ) + except NoPermissionError as exc: + raise Forbidden(str(exc)) from exc if result.status == ImportStatus.FAILED: session.rollback() else: @@ -108,7 +113,10 @@ class AppDslImportConfirmApi(Resource): with Session(db.engine, expire_on_commit=False) as session: service = AppDslService(session) - result = service.confirm_import(import_id=import_id, account=account) + try: + result = service.confirm_import(import_id=import_id, account=account) + except NoPermissionError as exc: + raise Forbidden(str(exc)) from exc if result.status == ImportStatus.FAILED: session.rollback() else: diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 48c9cd7cfc3..a67ae313813 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -19,7 +19,7 @@ from configs import dify_config from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.file import remote_fetcher from core.plugin.entities.plugin import PluginDependency -from core.rbac import RBACPermission +from core.rbac import RBACPermission, RBACResourceScope from core.trigger.constants import ( TRIGGER_PLUGIN_NODE_TYPE, TRIGGER_SCHEDULE_NODE_TYPE, @@ -226,9 +226,7 @@ class AppDslService: # If app_id is provided, check if it exists app = None if app_id: - stmt = select(App).where(App.id == app_id, App.tenant_id == account.current_tenant_id) - app = self._session.scalar(stmt) - + app = self._load_app_for_overwrite(account, app_id) if not app: return Import( id=import_id, @@ -366,8 +364,13 @@ class AppDslService: app = None if pending_data.app_id: - stmt = select(App).where(App.id == pending_data.app_id, App.tenant_id == account.current_tenant_id) - app = self._session.scalar(stmt) + app = self._load_app_for_overwrite(account, pending_data.app_id) + if not app: + return Import( + id=import_id, + status=ImportStatus.FAILED, + error="App not found", + ) # Create or update app app = self._create_or_update_app( @@ -428,6 +431,31 @@ class AppDslService: leaked_dependencies=leaked_dependencies, ) + def _load_app_for_overwrite(self, account: Account, app_id: str) -> App | None: + if account.current_tenant_id is None: + raise ValueError("Current tenant is not set") + if dify_config.RBAC_ENABLED and self._session.in_transaction(): + raise RuntimeError("App overwrite authorization requires a session without an active transaction") + rbac_allowed = not dify_config.RBAC_ENABLED or RBACService.CheckAccess.check( + account.current_tenant_id, + account.id, + scene=RBACPermission.APP_IMPORT_EXPORT_DSL, + resource_type=RBACResourceScope.APP, + resource_id=app_id, + ) + app = self._session.scalar( + select(App) + .where( + App.id == app_id, + App.tenant_id == account.current_tenant_id, + App.status == "normal", + ) + .execution_options(populate_existing=True) + ) + if app is not None and not rbac_allowed and app.maintainer != account.id: + raise NoPermissionError("You do not have permission to overwrite this app") + return app + @staticmethod def _ensure_agent_manage_permission(account: Account) -> None: """Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``.""" diff --git a/api/services/data_migration/import_service.py b/api/services/data_migration/import_service.py index c051be05d7a..960d58d92d8 100644 --- a/api/services/data_migration/import_service.py +++ b/api/services/data_migration/import_service.py @@ -18,6 +18,7 @@ import yaml from sqlalchemy import or_ from sqlalchemy.orm import Session, sessionmaker +from configs import dify_config from core.entities.mcp_provider import IdentityMode, MCPAuthentication, MCPConfiguration from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration from extensions.ext_database import db @@ -324,11 +325,14 @@ class MigrationImportService: ) -> str: import_service = AppDslService(session) if existing_app is not None: + existing_app_id = existing_app.id + if dify_config.RBAC_ENABLED: + session.commit() import_result = import_service.import_app( account=account, import_mode="yaml-content", yaml_content=dsl_content, - app_id=existing_app.id, + app_id=existing_app_id, ) else: import_app_id = app_id if self._should_preserve_source_app_id(options) else None diff --git a/api/services/workflow_collaboration_service.py b/api/services/workflow_collaboration_service.py index 11fae8ebbf6..8e1f0c8a01a 100644 --- a/api/services/workflow_collaboration_service.py +++ b/api/services/workflow_collaboration_service.py @@ -12,9 +12,12 @@ from socketio.exceptions import TimeoutError as SocketIOTimeoutError # type: ig from sqlalchemy import select from sqlalchemy.orm import Session +from configs import dify_config +from core.rbac import RBACPermission, RBACResourceScope from models.account import Account from models.model import App from repositories.workflow_collaboration_repository import WorkflowCollaborationRepository, WorkflowSessionInfo +from services.enterprise.rbac_service import RBACService logger = logging.getLogger(__name__) @@ -112,7 +115,7 @@ class WorkflowCollaborationService: if not user_id or not tenant_id: return None - if not self._can_access_workflow(workflow_id, str(tenant_id), session=session): + if not self._can_access_workflow(workflow_id, str(tenant_id), str(user_id), session=session): logger.warning( "Workflow collaboration join rejected: workflow_id=%s tenant_id=%s user_id=%s sid=%s", workflow_id, @@ -148,10 +151,27 @@ class WorkflowCollaborationService: return str(user_id), is_leader - def _can_access_workflow(self, workflow_id: str, tenant_id: str, *, session: Session) -> bool: - """Check room access without relying on Flask's app-context-bound scoped session.""" - app_id = session.scalar(select(App.id).where(App.id == workflow_id, App.tenant_id == tenant_id).limit(1)) - return app_id is not None + def _can_access_workflow(self, workflow_id: str, tenant_id: str, user_id: str, *, session: Session) -> bool: + """Check tenant and app permission without relying on Flask's scoped session.""" + with session.begin(): + app = session.execute( + select(App.id, App.maintainer).where( + App.id == workflow_id, App.tenant_id == tenant_id, App.status == "normal" + ) + ).one_or_none() + if app is None: + return False + + app_id, maintainer = app + if not dify_config.RBAC_ENABLED or maintainer == user_id: + return True + return RBACService.CheckAccess.check( + tenant_id, + user_id, + scene=RBACPermission.APP_EDIT, + resource_type=RBACResourceScope.APP, + resource_id=app_id, + ) def disconnect_session(self, sid: str) -> None: mapping = self._repository.get_sid_mapping(sid) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 5cad8988888..7a267427338 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -338,15 +338,17 @@ class WorkflowService: return workflow - def get_accessible_app_ids(self, app_ids: Sequence[str], tenant_id: str, *, session: Session) -> set[str]: - """ - Return app IDs that belong to the given tenant. - """ + def get_tenant_app_maintainers( + self, app_ids: Sequence[str], tenant_id: str, *, session: Session + ) -> dict[str, str | None]: + """Return requested normal apps and their maintainers within a tenant.""" if not app_ids: - return set() + return {} - stmt = select(App.id).where(App.id.in_(app_ids), App.tenant_id == tenant_id) - return {str(app_id) for app_id in session.scalars(stmt).all()} + stmt = select(App.id, App.maintainer).where( + App.id.in_(app_ids), App.tenant_id == tenant_id, App.status == "normal" + ) + return {str(app_id): maintainer for app_id, maintainer in session.execute(stmt)} def get_all_published_workflow( self, diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index f01ddcaba64..bf59f8a32ad 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -846,12 +846,19 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp app_id_2 = "22222222-2222-2222-2222-222222222222" signed_avatar_url = "https://files.example.com/signed/avatar-1" sign_avatar = Mock(return_value=signed_avatar_url) + get_tenant_app_maintainers = Mock(return_value={app_id_1: "owner-1", app_id_2: "owner-2"}) monkeypatch.setattr( workflow_module, "WorkflowService", - lambda: SimpleNamespace(get_accessible_app_ids=lambda app_ids, tenant_id, session: {app_id_1}), + lambda: SimpleNamespace(get_tenant_app_maintainers=get_tenant_app_maintainers), ) + access_filter = SimpleNamespace(is_app_accessible=lambda app_id, _maintainer, _account_id: app_id == app_id_1) + resolve_access = Mock(return_value=access_filter) + monkeypatch.setattr(workflow_module, "resolve_app_access_filter", resolve_access) + monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", True) monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar) + short_session = Mock() + monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(short_session)) redis_pipeline = Mock() redis_pipeline.execute.return_value = [ @@ -899,7 +906,7 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp method="POST", json={"app_ids": [app_id_1, app_id_2]}, ): - response = handler(api, "tenant-1") + response = handler(api, "tenant-1", SimpleNamespace(id="account-1")) assert response == { "data": [ @@ -924,6 +931,11 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp redis_pipeline.hgetall.assert_called_once_with(f"{workflow_module.WORKFLOW_ONLINE_USERS_PREFIX}{app_id_1}") redis_pipeline.execute.assert_called_once_with() sign_avatar.assert_called_once_with("avatar-file-id") + get_tenant_app_maintainers.assert_called_once() + resolve_access.assert_called_once() + assert get_tenant_app_maintainers.call_args.args == ([app_id_1, app_id_2], "tenant-1") + assert resolve_access.call_args.args == ("tenant-1", "account-1") + assert get_tenant_app_maintainers.call_args.kwargs["session"] is resolve_access.call_args.kwargs["session"] def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -931,8 +943,10 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte monkeypatch.setattr( workflow_module, "WorkflowService", - lambda: SimpleNamespace(get_accessible_app_ids=lambda app_ids, tenant_id, session: set(app_ids)), + lambda: SimpleNamespace(get_tenant_app_maintainers=lambda app_ids, tenant_id, session: dict.fromkeys(app_ids)), ) + monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", False) + monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(Mock())) first_pipeline = Mock() first_pipeline.execute.return_value = [{} for _ in range(workflow_module.WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE)] @@ -949,7 +963,7 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte method="POST", json={"app_ids": app_ids}, ): - response = handler(api, "tenant-1") + response = handler(api, "tenant-1", SimpleNamespace(id="account-1")) assert len(response["data"]) == len(app_ids) assert redis_pipeline_factory.call_count == 2 @@ -958,11 +972,11 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte def test_workflow_online_users_rejects_excessive_workflow_ids(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - accessible_app_ids = Mock(return_value=set()) + get_tenant_app_maintainers = Mock(return_value={}) monkeypatch.setattr( workflow_module, "WorkflowService", - lambda: SimpleNamespace(get_accessible_app_ids=accessible_app_ids), + lambda: SimpleNamespace(get_tenant_app_maintainers=get_tenant_app_maintainers), ) excessive_ids = [f"wf-{index}" for index in range(workflow_module.MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS + 1)] @@ -976,9 +990,9 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app: Flask, monkey json={"app_ids": excessive_ids}, ): with pytest.raises(HTTPException) as exc: - handler(api, "tenant-1") + handler(api, "tenant-1", SimpleNamespace(id="account-1")) assert exc.value.code == 400 assert exc.value.description is not None assert "Maximum" in exc.value.description - accessible_app_ids.assert_not_called() + get_tenant_app_maintainers.assert_not_called() diff --git a/api/tests/unit_tests/controllers/openapi/test_app_dsl.py b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py new file mode 100644 index 00000000000..25b0cd85917 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_app_dsl.py @@ -0,0 +1,49 @@ +from inspect import unwrap +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from flask import Flask +from sqlalchemy.engine import Engine +from werkzeug.exceptions import Forbidden + +from controllers.openapi import app_dsl as app_dsl_module +from controllers.openapi._models import AppDslImportPayload +from controllers.openapi.app_dsl import AppDslImportApi, AppDslImportConfirmApi +from services.errors.account import NoPermissionError + + +@pytest.mark.parametrize( + ("api", "kwargs"), + [ + ( + AppDslImportApi(), + { + "workspace_id": "workspace-1", + "body": AppDslImportPayload(mode="yaml-content", yaml_content="app: {}"), + }, + ), + ( + AppDslImportConfirmApi(), + {"workspace_id": "workspace-1", "import_id": "import-1"}, + ), + ], +) +def test_permission_denial_maps_to_forbidden( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + sqlite_engine: Engine, + api: AppDslImportApi | AppDslImportConfirmApi, + kwargs: dict[str, object], +) -> None: + service = Mock() + service.import_app.side_effect = NoPermissionError("denied") + service.confirm_import.side_effect = NoPermissionError("denied") + monkeypatch.setattr(app_dsl_module, "AppDslService", Mock(return_value=service)) + monkeypatch.setattr(app_dsl_module, "db", SimpleNamespace(engine=sqlite_engine)) + + with app.test_request_context("/openapi/v1/workspaces/workspace-1/apps/imports", method="POST"): + with pytest.raises(Forbidden, match="denied") as exc_info: + unwrap(api.post)(api, auth_data=SimpleNamespace(caller=Mock()), **kwargs) + + assert isinstance(exc_info.value.__cause__, NoPermissionError) diff --git a/api/tests/unit_tests/services/data_migration/test_import_service.py b/api/tests/unit_tests/services/data_migration/test_import_service.py index a5a1fdfd172..80e155c1dae 100644 --- a/api/tests/unit_tests/services/data_migration/test_import_service.py +++ b/api/tests/unit_tests/services/data_migration/test_import_service.py @@ -335,7 +335,7 @@ def test_find_existing_mcp_tool_does_not_compare_invalid_uuid(database: Database assert f"{MCPToolProvider.__tablename__}.name" not in where_clause -def test_workflow_app_import_does_not_wrap_app_dsl_import_in_nested_transaction( +def test_workflow_app_import_closes_read_transaction_before_dsl_overwrite( monkeypatch: pytest.MonkeyPatch, database: Database ): class StubAppDslService: @@ -343,32 +343,25 @@ def test_workflow_app_import_does_not_wrap_app_dsl_import_in_nested_transaction( self.session = session def import_app(self, **kwargs): + assert not self.session.in_transaction() return Import(id="import-id", status=ImportStatus.COMPLETED, app_id="imported-app-id") monkeypatch.setattr(import_service, "AppDslService", StubAppDslService) - nested_transactions = [] + monkeypatch.setattr(import_service.dify_config, "RBAC_ENABLED", True) + existing_app = _persist_app(database.session, app_id="11111111-1111-4111-8111-111111111111") + database.session.begin() - def capture_transaction(_session, transaction) -> None: - if transaction.nested: - nested_transactions.append(transaction) - - event.listen(database.session, "after_transaction_create", capture_transaction) - - try: - imported_app_id = MigrationImportService()._import_workflow_app( - account=object(), - workflow_data={"name": "main_chatflow"}, - dsl_content="app:\n mode: workflow\n", - app_id="source-app-id", - existing_app=None, - options=ImportOptions(id_strategy=IdStrategy.PRESERVE_ID), - session=database.session, - ) - finally: - event.remove(database.session, "after_transaction_create", capture_transaction) + imported_app_id = MigrationImportService()._import_workflow_app( + account=object(), + workflow_data={"name": "main_chatflow"}, + dsl_content="app:\n mode: workflow\n", + app_id="source-app-id", + existing_app=existing_app, + options=ImportOptions(id_strategy=IdStrategy.PRESERVE_ID), + session=database.session, + ) assert imported_app_id == "imported-app-id" - assert nested_transactions == [] def test_rewrite_workflow_dsl_replaces_tool_provider_ids(): diff --git a/api/tests/unit_tests/services/test_app_dsl_service.py b/api/tests/unit_tests/services/test_app_dsl_service.py index afb681cdf44..9b76c0938c4 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -7,7 +7,7 @@ import yaml from sqlalchemy import event, select from sqlalchemy.orm import Session, sessionmaker -from core.rbac import RBACPermission +from core.rbac import RBACPermission, RBACResourceScope from core.workflow.llm_environment_variable import LLMEnvironmentVariable from models import App, AppMode from models.model import AppModelConfig, AppModelConfigDict, IconType @@ -17,6 +17,40 @@ from services.entities.dsl_entities import ImportStatus from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError +_OVERWRITE_APP_ID = "11111111-1111-4111-8111-111111111111" +_TENANT_ID = "22222222-2222-4222-8222-222222222222" +_CALLER_ID = "33333333-3333-4333-8333-333333333333" +_OTHER_ACCOUNT_ID = "44444444-4444-4444-8444-444444444444" +_PENDING_WORKFLOW_DSL = "version: 99.0.0\nkind: app\napp: {name: Test, mode: workflow}\n" +_PENDING_DATA_JSON = PendingData( + tenant_id=_TENANT_ID, + account_id=_CALLER_ID, + import_mode="yaml-content", + yaml_content=_PENDING_WORKFLOW_DSL, + app_id=_OVERWRITE_APP_ID, +).model_dump_json() + + +def _persist_overwrite_target(session: Session, *, maintainer: str = _OTHER_ACCOUNT_ID) -> App: + app = App( + id=_OVERWRITE_APP_ID, + tenant_id=_TENANT_ID, + name="Target", + description="", + mode=AppMode.WORKFLOW, + icon_type=IconType.EMOJI, + icon="robot", + icon_background="#FFFFFF", + enable_site=True, + enable_api=True, + created_by=maintainer, + maintainer=maintainer, + updated_by=maintainer, + ) + session.add(app) + session.commit() + return app + def test_extract_workflow_dependencies_uses_llm_environment_variable_provider(monkeypatch: pytest.MonkeyPatch) -> None: workflow = SimpleNamespace( @@ -145,6 +179,99 @@ def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes( assert not unbound_session.in_transaction() +def test_import_app_checks_overwrite_rbac_before_database_access( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + _persist_overwrite_target(sqlite_session) + account = Mock(id=_CALLER_ID, current_tenant_id=_TENANT_ID) + + def deny_before_transaction(*_args: object, **_kwargs: object) -> bool: + assert not sqlite_session.in_transaction() + return False + + check = Mock(side_effect=deny_before_transaction) + setex = Mock() + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) + monkeypatch.setattr("services.app_dsl_service.redis_client.setex", setex) + + with pytest.raises(NoPermissionError, match="permission to overwrite"): + AppDslService(sqlite_session).import_app( + account=account, + import_mode="yaml-content", + yaml_content=_PENDING_WORKFLOW_DSL, + app_id=_OVERWRITE_APP_ID, + ) + + check.assert_called_once_with( + _TENANT_ID, + _CALLER_ID, + scene=RBACPermission.APP_IMPORT_EXPORT_DSL, + resource_type=RBACResourceScope.APP, + resource_id=_OVERWRITE_APP_ID, + ) + setex.assert_not_called() + + +def test_confirm_import_rechecks_overwrite_rbac_before_database_access( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + _persist_overwrite_target(sqlite_session) + monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=_PENDING_DATA_JSON)) + redis_delete = Mock() + monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete) + create_or_update = Mock() + service = AppDslService(sqlite_session) + monkeypatch.setattr(service, "_create_or_update_app", create_or_update) + + def deny_before_transaction(*_args: object, **_kwargs: object) -> bool: + assert not sqlite_session.in_transaction() + return False + + check = Mock(side_effect=deny_before_transaction) + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) + + with pytest.raises(NoPermissionError, match="permission to overwrite"): + service.confirm_import( + import_id="import-1", + account=Mock(id=_CALLER_ID, current_tenant_id=_TENANT_ID), + ) + + check.assert_called_once_with( + _TENANT_ID, + _CALLER_ID, + scene=RBACPermission.APP_IMPORT_EXPORT_DSL, + resource_type=RBACResourceScope.APP, + resource_id=_OVERWRITE_APP_ID, + ) + create_or_update.assert_not_called() + redis_delete.assert_not_called() + + +def test_confirm_import_does_not_create_when_overwrite_target_disappeared( + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session +) -> None: + monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=_PENDING_DATA_JSON)) + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=True)) + redis_delete = Mock() + monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete) + service = AppDslService(sqlite_session) + create_or_update = Mock() + monkeypatch.setattr(service, "_create_or_update_app", create_or_update) + + result = service.confirm_import( + import_id="import-1", + account=Mock(id=_CALLER_ID, current_tenant_id=_TENANT_ID), + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "App not found" + create_or_update.assert_not_called() + redis_delete.assert_not_called() + + def test_pending_import_is_scoped_to_its_owner(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None: pending_imports: dict[str, str] = {} monkeypatch.setattr( diff --git a/api/tests/unit_tests/services/test_workflow_collaboration_service.py b/api/tests/unit_tests/services/test_workflow_collaboration_service.py index 77e55b545c3..3cf1f3c0a21 100644 --- a/api/tests/unit_tests/services/test_workflow_collaboration_service.py +++ b/api/tests/unit_tests/services/test_workflow_collaboration_service.py @@ -8,6 +8,7 @@ from socketio.exceptions import TimeoutError as SocketIOTimeoutError from sqlalchemy import Engine from sqlalchemy.orm import Session +from core.rbac import RBACPermission, RBACResourceScope from models.account import Account, Tenant from models.base import TypeBase from models.model import App, AppMode, IconType @@ -24,7 +25,7 @@ def db_session(sqlite_engine: Engine) -> Iterator[Session]: yield session -def _app(*, app_id: str, tenant_id: str) -> App: +def _app(*, app_id: str, tenant_id: str, maintainer: str | None = None) -> App: return App( id=app_id, tenant_id=tenant_id, @@ -42,6 +43,7 @@ def _app(*, app_id: str, tenant_id: str) -> App: is_public=False, is_universal=False, max_active_requests=None, + maintainer=maintainer, use_icon_as_answer_icon=False, ) @@ -65,23 +67,37 @@ class TestWorkflowCollaborationService: "avatar": None, "tenant_id": "t-1", } + db_session.add(_app(app_id="wf-1", tenant_id="t-1", maintainer="owner-1")) + db_session.commit() with ( - patch.object(collaboration_service, "_can_access_workflow", return_value=True), - patch.object(collaboration_service, "get_or_set_leader", return_value="sid-1"), - patch.object(collaboration_service, "broadcast_online_users"), + patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + patch( + "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=True + ) as check_access, + patch.object(collaboration_service, "get_or_set_leader", return_value="sid-1") as get_leader, + patch.object(collaboration_service, "broadcast_online_users") as broadcast_online_users, ): # Act result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session) # Assert assert result == ("u-1", True) + check_access.assert_called_once_with( + "t-1", + "u-1", + scene=RBACPermission.APP_EDIT, + resource_type=RBACResourceScope.APP, + resource_id="wf-1", + ) repository.set_session_info.assert_called_once() session_info = repository.set_session_info.call_args.args[1] assert session_info["server_id"] == "server-1" repository.refresh_server_heartbeat.assert_called_once_with("server-1") socketio.start_background_task.assert_called_once() + get_leader.assert_called_once_with("wf-1", "sid-1") socketio.enter_room.assert_called_once_with("sid-1", "wf-1") + broadcast_online_users.assert_called_once_with("wf-1") socketio.emit.assert_called_once_with("status", {"isLeader": True}, room="sid-1") def test_authorize_and_join_workflow_room_returns_none_when_missing_user( @@ -120,13 +136,33 @@ class TestWorkflowCollaborationService: "avatar": None, "tenant_id": "t-1", } + db_session.add(_app(app_id="wf-1", tenant_id="t-1", maintainer="owner-1")) + db_session.commit() - with patch.object(collaboration_service, "_can_access_workflow", return_value=False): + with ( + patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + patch( + "services.workflow_collaboration_service.RBACService.CheckAccess.check", return_value=False + ) as check_access, + patch.object(collaboration_service, "get_or_set_leader") as get_leader, + patch.object(collaboration_service, "broadcast_online_users") as broadcast_online_users, + ): result = collaboration_service.authorize_and_join_workflow_room("wf-1", "sid-1", session=db_session) assert result is None + check_access.assert_called_once_with( + "t-1", + "u-1", + scene=RBACPermission.APP_EDIT, + resource_type=RBACResourceScope.APP, + resource_id="wf-1", + ) + repository.refresh_server_heartbeat.assert_not_called() repository.set_session_info.assert_not_called() + socketio.start_background_task.assert_not_called() + get_leader.assert_not_called() socketio.enter_room.assert_not_called() + broadcast_online_users.assert_not_called() socketio.emit.assert_not_called() def test_repr_and_save_socket_identity(self, service: tuple[WorkflowCollaborationService, Mock, Mock]) -> None: @@ -159,11 +195,34 @@ class TestWorkflowCollaborationService: ) db_session.commit() - result = collaboration_service._can_access_workflow("wf-1", "tenant-1", session=db_session) + with patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", False): + result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "user-1", session=db_session) + + assert result is True + assert ( + collaboration_service._can_access_workflow("wf-1", "tenant-other", "user-1", session=db_session) + is False + ) + assert ( + collaboration_service._can_access_workflow("wf-other", "tenant-other", "user-1", session=db_session) + is True + ) + + def test_can_access_workflow_allows_maintainer_without_rbac_call( + self, service: tuple[WorkflowCollaborationService, Mock, Mock], db_session: Session + ) -> None: + collaboration_service, _repository, _socketio = service + db_session.add(_app(app_id="wf-1", tenant_id="tenant-1", maintainer="owner-1")) + db_session.commit() + + with ( + patch("services.workflow_collaboration_service.dify_config.RBAC_ENABLED", True), + patch("services.workflow_collaboration_service.RBACService.CheckAccess.check") as check_access, + ): + result = collaboration_service._can_access_workflow("wf-1", "tenant-1", "owner-1", session=db_session) assert result is True - assert collaboration_service._can_access_workflow("wf-1", "tenant-other", session=db_session) is False - assert collaboration_service._can_access_workflow("wf-other", "tenant-other", session=db_session) is True + check_access.assert_not_called() def test_relay_collaboration_event_unauthorized( self, service: tuple[WorkflowCollaborationService, Mock, Mock] diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 2fff05a4ed9..6b77c96e568 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -231,6 +231,26 @@ class TestWorkflowService: """Create a WorkflowService whose repositories use the test SQLite engine.""" return WorkflowService(sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + def test_get_tenant_app_maintainers_scopes_requested_apps( + self, workflow_service: WorkflowService, sqlite_session: Session + ) -> None: + sqlite_session.add_all( + [ + TestWorkflowAssociatedDataFactory.create_app( + app_id="app-1", tenant_id="tenant-1", maintainer="owner-1" + ), + TestWorkflowAssociatedDataFactory.create_app(app_id="app-2", tenant_id="tenant-1"), + TestWorkflowAssociatedDataFactory.create_app( + app_id="app-3", tenant_id="tenant-2", maintainer="owner-2" + ), + ] + ) + sqlite_session.commit() + + assert workflow_service.get_tenant_app_maintainers( + ["app-1", "app-2", "app-3", "missing"], "tenant-1", session=sqlite_session + ) == {"app-1": "owner-1", "app-2": None} + # ==================== Workflow Existence Tests ==================== # These tests verify the service can check if a draft workflow exists diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 75d2a7371ae..157c0ca450b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3365,14 +3365,6 @@ "count": 1 } }, - "web/app/components/workflow/header/online-users.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 2 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 2 - } - }, "web/app/components/workflow/header/test-run-menu.tsx": { "erasable-syntax-only/enums": { "count": 1 diff --git a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx index 600a6a404d0..c1af965355c 100644 --- a/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx +++ b/web/app/components/workflow-app/components/__tests__/workflow-main.spec.tsx @@ -5,6 +5,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import { BlockEnum } from '@/app/components/workflow/types' import { renderWithAccountProfile as render } from '@/test/console/account-profile' +import { AppACLPermission } from '@/utils/permission' import WorkflowMain from '../workflow-main' const mockSetFeatures = vi.fn() @@ -24,6 +25,7 @@ const mockReplaceGraphFromReactFlow = vi.hoisted(() => vi.fn()) const mockCanPersistLocalGraph = vi.hoisted(() => vi.fn()) const mockIsGraphReloadCurrent = vi.hoisted(() => vi.fn()) const mockRetryGraphReload = vi.hoisted(() => vi.fn()) +const mockUseCollaboration = vi.hoisted(() => vi.fn()) const hookFns = { doSyncWorkflowDraft: vi.fn(), @@ -135,14 +137,10 @@ vi.mock('reactflow', () => ({ })) vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({ - useCollaboration: () => ({ - startCursorTracking: collaborationRuntime.startCursorTracking, - stopCursorTracking: collaborationRuntime.stopCursorTracking, - onlineUsers: collaborationRuntime.onlineUsers, - cursors: collaborationRuntime.cursors, - isConnected: collaborationRuntime.isConnected, - isEnabled: collaborationRuntime.isEnabled, - }), + useCollaboration: (...args: unknown[]) => { + mockUseCollaboration(...args) + return collaborationRuntime + }, })) vi.mock('@/app/components/workflow/hooks/use-workflow-update', () => ({ @@ -590,6 +588,16 @@ describe('WorkflowMain', () => { expect(screen.queryByRole('status')).not.toBeInTheDocument() }) + it('disables collaboration for view-only apps', () => { + useAppStore.setState({ + appDetail: { permission_keys: [AppACLPermission.ViewLayout] } as never, + }) + + render() + + expect(mockUseCollaboration).toHaveBeenCalledWith('app-1', false, expect.any(Object)) + }) + it('subscribes collaboration listeners and handles sync/workflow update callbacks', async () => { collaborationRuntime.isEnabled = true mockFetchWorkflowDraft.mockResolvedValue({ diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 14bb178ff12..dfa7666aef6 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -74,22 +74,6 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { }), [reactFlow], ) - const { - startCursorTracking, - stopCursorTracking, - onlineUsers, - cursors, - isConnected, - isEnabled: isCollaborationEnabled, - } = useCollaboration(appId || '', reactFlowStore) - const myUserId = useMemo( - () => (isCollaborationEnabled && isConnected ? 'current-user' : null), - [isCollaborationEnabled, isConnected], - ) - - const filteredCursors = Object.fromEntries( - Object.entries(cursors).filter(([userId]) => userId !== myUserId), - ) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, @@ -104,6 +88,22 @@ const WorkflowMain = ({ nodes, edges, viewport }: WorkflowMainProps) => { }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys], ) + const { + startCursorTracking, + stopCursorTracking, + onlineUsers, + cursors, + isConnected, + isEnabled: isCollaborationEnabled, + } = useCollaboration(appId || '', appACLCapabilities.canEdit, reactFlowStore) + const myUserId = useMemo( + () => (isCollaborationEnabled && isConnected ? 'current-user' : null), + [isCollaborationEnabled, isConnected], + ) + + const filteredCursors = Object.fromEntries( + Object.entries(cursors).filter(([userId]) => userId !== myUserId), + ) useEffect(() => { if (!isCollaborationEnabled) return diff --git a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts index 828d8275e8a..61b079e74ee 100644 --- a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts +++ b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts @@ -3,7 +3,7 @@ import { waitFor } from '@testing-library/react' import { renderHookWithConsoleQuery } from '@/test/console/query-data' import { useCollaboration } from '../use-collaboration' -type HookReactFlowStore = NonNullable[1]> +type HookReactFlowStore = NonNullable[2]> type HookReactFlowInstance = Parameters< ReturnType['startCursorTracking'] >[1] @@ -105,7 +105,7 @@ describe('useCollaboration', () => { getState: vi.fn(), } const { result, unmount } = renderHookWithConsoleQuery( - () => useCollaboration('app-1', reactFlowStore), + () => useCollaboration('app-1', true, reactFlowStore), { systemFeatures: { enable_collaboration_mode: isCollaborationEnabled }, }, @@ -155,18 +155,24 @@ describe('useCollaboration', () => { expect(mockDisconnect).toHaveBeenCalledWith('conn-1') }) - it('does not connect or start cursor tracking when collaboration is disabled', async () => { - isCollaborationEnabled = false - const { result } = renderHookWithConsoleQuery(() => useCollaboration('app-1'), { - systemFeatures: { enable_collaboration_mode: isCollaborationEnabled }, - }) + it.each([ + [false, true], + [true, false], + ])( + 'does not connect or track cursors when a collaboration gate is disabled', + async (featureEnabled, canEdit) => { + isCollaborationEnabled = featureEnabled + const { result } = renderHookWithConsoleQuery(() => useCollaboration('app-1', canEdit), { + systemFeatures: { enable_collaboration_mode: featureEnabled }, + }) - await waitFor(() => { - expect(mockConnect).not.toHaveBeenCalled() - expect(result.current.isEnabled).toBe(false) - }) + await waitFor(() => { + expect(mockConnect).not.toHaveBeenCalled() + expect(result.current.isEnabled).toBe(false) + }) - result.current.startCursorTracking({ current: document.createElement('div') }) - expect(mockStartTracking).not.toHaveBeenCalled() - }) + result.current.startCursorTracking({ current: document.createElement('div') }) + expect(mockStartTracking).not.toHaveBeenCalled() + }, + ) }) diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts index 12f6436263b..4d4002999a2 100644 --- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts +++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts @@ -29,7 +29,7 @@ const initialState: CollaborationViewState = { isLeader: false, } -export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) { +export function useCollaboration(appId: string, canEdit: boolean, reactFlowStore?: ReactFlowStore) { const [state, setState] = useState(initialState) const cursorServiceRef = useRef(null) @@ -40,7 +40,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) }) useEffect(() => { - if (!appId || !isCollaborationEnabled) { + if (!appId || !isCollaborationEnabled || !canEdit) { Promise.resolve().then(() => { setState(initialState) }) @@ -110,7 +110,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) cursorServiceRef.current?.stopTracking() if (connectionId) collaborationManager.disconnect(connectionId) } - }, [appId, reactFlowStore, isCollaborationEnabled]) + }, [appId, canEdit, reactFlowStore, isCollaborationEnabled]) const prevIsConnected = useRef(false) useEffect(() => { @@ -126,7 +126,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) containerRef: React.RefObject, reactFlowInstance?: ReactFlowInstance, ) => { - if (!isCollaborationEnabled || !cursorServiceRef.current) return + if (!isCollaborationEnabled || !canEdit || !cursorServiceRef.current) return if (cursorServiceRef.current) { cursorServiceRef.current.startTracking( @@ -150,7 +150,7 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore) nodePanelPresence: state.nodePanelPresence || {}, isLeader: state.isLeader || false, leaderId: collaborationManager.getLeaderId(), - isEnabled: isCollaborationEnabled, + isEnabled: isCollaborationEnabled && canEdit, startCursorTracking, stopCursorTracking, } diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index 427b9423e63..580ddaaefd9 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -14,6 +14,7 @@ import { userProfileQueryOptions } from '@/features/account-profile/client' import { getAvatar } from '@/service/common' import { useCollaboration } from '../collaboration/hooks/use-collaboration' import { getUserColor } from '../collaboration/utils/user-color' +import { useHooksStore } from '../hooks-store' import { useStore } from '../store' const useAvatarUrls = (users: OnlineUser[]) => { @@ -49,11 +50,12 @@ const useAvatarUrls = (users: OnlineUser[]) => { const OnlineUsers = () => { const { t } = useTranslation() const appId = useStore((s) => s.appId) + const canEdit = useHooksStore((s) => s.accessControl.canEdit) const { onlineUsers, cursors, isEnabled: isCollaborationEnabled, - } = useCollaboration(appId as string) + } = useCollaboration(appId as string, canEdit) const { data: currentUserId } = useSuspenseQuery({ ...userProfileQueryOptions(), select: (data) => data.profile.id, @@ -119,29 +121,43 @@ const OnlineUsers = () => { const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) const avatarUrl = getAvatarUrl(user) const displayName = user.username || fallbackUsername + const avatar = ( + + {avatarUrl && } + + {displayName?.[0]?.toLocaleUpperCase()} + + + ) + const triggerClassName = cn( + 'relative flex size-6 items-center justify-center', + index > 0 && '-ml-1.5', + !isCurrentUser && 'cursor-pointer transition-transform hover:scale-110', + ) + const triggerStyle = { zIndex: visibleUsers.length - index } return ( - - -
0 && '-ml-1.5', - !isCurrentUser && 'cursor-pointer transition-transform hover:scale-110', - )} - style={{ zIndex: visibleUsers.length - index }} - onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)} - > - - {avatarUrl && } - + + {avatar} +
+ ) : ( + + ) + } + /> { const avatarUrl = getAvatarUrl(user) const displayName = user.username || fallbackUsername return ( -
{ - if (!isCurrentUser) { - jumpToUserCursor(user.user_id) - setDropdownOpen(false) - } + jumpToUserCursor(user.user_id) + setDropdownOpen(false) }} >
@@ -219,7 +235,7 @@ const OnlineUsers = () => { 'system-xs-medium text-text-secondary', 'text-text-tertiary', )} -
+ ) })} diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 8956c99e41c..2c035f07b2b 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -100,7 +100,8 @@ const BasePanel: FC = ({ id, data, children }) => { ...userProfileQueryOptions(), select: (data) => data.profile, }) - const { isConnected, nodePanelPresence } = useCollaboration(appId as string) + const canEdit = useHooksStore((s) => s.accessControl.canEdit) + const { isConnected, nodePanelPresence } = useCollaboration(appId as string, canEdit) const { showMessageLogModal } = useAppStore( useShallow((state) => ({ showMessageLogModal: state.showMessageLogModal, diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 5990ddad373..61b255cd095 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -9,6 +9,7 @@ import { UserAvatarList } from '@/app/components/base/user-avatar-list' import BlockIcon from '@/app/components/workflow/block-icon' import { ToolType } from '@/app/components/workflow/block-selector/types' import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/use-collaboration' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions' import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions' import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id' @@ -62,7 +63,8 @@ const BaseNode: FC = ({ id, data, children }) => { select: (data) => data.profile, }) const appId = useStore((s) => s.appId) - const { nodePanelPresence } = useCollaboration(appId as string) + const canEdit = useHooksStore((s) => s.accessControl.canEdit) + const { nodePanelPresence } = useCollaboration(appId as string, canEdit) const controlMode = useStore((s) => s.controlMode) const isContextMenuTarget = useStore( (s) => s.contextMenuTarget?.type === 'node' && s.contextMenuTarget.nodeId === id,