From b56ac4af4d1470eedd68236d581174a0c29d57b2 Mon Sep 17 00:00:00 2001 From: Xiyuan Chen <52963600+GareArc@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:34:07 -0700 Subject: [PATCH] feat: gate agent management behind the agent.manage permission (#39330) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- api/controllers/common/wraps.py | 27 +++- api/controllers/console/agent/composer.py | 3 + api/controllers/console/agent/roster.py | 20 +++ api/controllers/console/apikey.py | 4 + api/controllers/console/app/app.py | 37 +++-- api/controllers/console/app/app_import.py | 34 ++-- api/controllers/console/app/site.py | 4 +- api/controllers/console/app/wraps.py | 49 +++++- api/core/rbac/entities.py | 1 + api/models/model.py | 46 ++++-- api/services/agent/roster_service.py | 40 ++++- api/services/app_dsl_service.py | 26 +++ api/services/enterprise/rbac_service.py | 3 + .../console/app/test_agent_manage_guard.py | 150 ++++++++++++++++++ .../controllers/console/test_wraps.py | 55 +++++-- .../services/agent/test_agent_services.py | 32 ++++ .../services/enterprise/test_rbac_service.py | 17 ++ .../services/test_app_dsl_service.py | 60 +++++++ web/.env.example | 2 + .../__tests__/agents-access-guard.spec.tsx | 110 +++++++++++++ .../agents/__tests__/layout.spec.tsx | 7 + .../agents/agents-access-guard.tsx | 30 ++++ web/app/(commonLayout)/agents/layout.tsx | 3 +- .../main-nav/__tests__/index.spec.tsx | 20 ++- web/app/components/main-nav/index.tsx | 4 + web/app/components/main-nav/routes.ts | 27 ++-- .../__tests__/agent-selector.spec.tsx | 90 +++++++++++ .../block-selector/__tests__/blocks.spec.tsx | 6 + .../block-selector/agent-selector.tsx | 23 ++- .../nodes/agent-v2/__tests__/panel.spec.tsx | 4 + .../agent-orchestrate-panel-content.spec.tsx | 20 +++ .../__tests__/agent-roster-field.spec.tsx | 60 +++++++ .../__tests__/edit-in-console-link.spec.tsx | 21 +++ .../agent-orchestrate-panel-content.tsx | 4 + .../components/agent-roster-field.tsx | 19 +-- .../components/edit-in-console-link.tsx | 56 +++++++ web/config/__tests__/index.spec.ts | 59 +++++++ web/config/index.ts | 1 + web/docker/entrypoint.sh | 1 + web/env.ts | 7 + .../__tests__/community-edition-tip.spec.tsx | 33 ++++ .../components/community-edition-tip.tsx | 53 +++++++ .../components/orchestrate/header.tsx | 28 +--- .../components/preview/build-chat.tsx | 32 +--- web/features/agent-v2/permissions.ts | 13 ++ web/i18n/ar-TN/permission-keys.json | 1 + web/i18n/ar-TN/workflow.json | 1 + web/i18n/de-DE/permission-keys.json | 1 + web/i18n/de-DE/workflow.json | 1 + web/i18n/en-US/permission-keys.json | 1 + web/i18n/en-US/workflow.json | 1 + web/i18n/es-ES/permission-keys.json | 1 + web/i18n/es-ES/workflow.json | 1 + web/i18n/fa-IR/permission-keys.json | 1 + web/i18n/fa-IR/workflow.json | 1 + web/i18n/fr-FR/permission-keys.json | 1 + web/i18n/fr-FR/workflow.json | 1 + web/i18n/hi-IN/permission-keys.json | 1 + web/i18n/hi-IN/workflow.json | 1 + web/i18n/id-ID/permission-keys.json | 1 + web/i18n/id-ID/workflow.json | 1 + web/i18n/it-IT/permission-keys.json | 1 + web/i18n/it-IT/workflow.json | 1 + web/i18n/ja-JP/permission-keys.json | 1 + web/i18n/ja-JP/workflow.json | 1 + web/i18n/ko-KR/permission-keys.json | 1 + web/i18n/ko-KR/workflow.json | 1 + web/i18n/nl-NL/permission-keys.json | 1 + web/i18n/nl-NL/workflow.json | 1 + web/i18n/pl-PL/permission-keys.json | 1 + web/i18n/pl-PL/workflow.json | 1 + web/i18n/pt-BR/permission-keys.json | 1 + web/i18n/pt-BR/workflow.json | 1 + web/i18n/ro-RO/permission-keys.json | 1 + web/i18n/ro-RO/workflow.json | 1 + web/i18n/ru-RU/permission-keys.json | 1 + web/i18n/ru-RU/workflow.json | 1 + web/i18n/sl-SI/permission-keys.json | 1 + web/i18n/sl-SI/workflow.json | 1 + web/i18n/th-TH/permission-keys.json | 1 + web/i18n/th-TH/workflow.json | 1 + web/i18n/tr-TR/permission-keys.json | 1 + web/i18n/tr-TR/workflow.json | 1 + web/i18n/uk-UA/permission-keys.json | 1 + web/i18n/uk-UA/workflow.json | 1 + web/i18n/vi-VN/permission-keys.json | 1 + web/i18n/vi-VN/workflow.json | 1 + web/i18n/zh-Hans/permission-keys.json | 1 + web/i18n/zh-Hans/workflow.json | 1 + web/i18n/zh-Hant/permission-keys.json | 1 + web/i18n/zh-Hant/workflow.json | 1 + 91 files changed, 1234 insertions(+), 153 deletions(-) create mode 100644 api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py create mode 100644 web/app/(commonLayout)/agents/__tests__/agents-access-guard.spec.tsx create mode 100644 web/app/(commonLayout)/agents/agents-access-guard.tsx create mode 100644 web/app/components/workflow/block-selector/__tests__/agent-selector.spec.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/components/__tests__/edit-in-console-link.spec.tsx create mode 100644 web/app/components/workflow/nodes/agent-v2/components/edit-in-console-link.tsx create mode 100644 web/config/__tests__/index.spec.ts create mode 100644 web/features/agent-v2/agent-detail/configure/components/__tests__/community-edition-tip.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/community-edition-tip.tsx create mode 100644 web/features/agent-v2/permissions.ts diff --git a/api/controllers/common/wraps.py b/api/controllers/common/wraps.py index 29b1fc44e5d..34fe8cc96aa 100644 --- a/api/controllers/common/wraps.py +++ b/api/controllers/common/wraps.py @@ -10,6 +10,7 @@ from extensions.ext_database import db from libs.login import current_account_with_tenant from models.dataset import Dataset from models.model import App +from services.agent.roster_service import AgentRosterService from services.enterprise.rbac_service import RBACService __all__ = ["RBACPermission", "RBACResourceScope", "enforce_rbac_access", "rbac_permission_required"] @@ -51,7 +52,7 @@ def enforce_rbac_access( check_resource_type = None if resource_type == RBACResourceScope.WORKSPACE else resource_type resource_id = None if resource_required and check_resource_type: - resource_id = _extract_resource_id(resource_type, path_args) + resource_id = _extract_resource_id(resource_type, tenant_id, path_args) if _is_resource_owned_by_current_user(tenant_id, account_id, resource_type, resource_id): return allowed = RBACService.CheckAccess.check( @@ -131,11 +132,14 @@ def _is_resource_owned_by_current_user( return False -def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, object] | None = None) -> str: +def _extract_resource_id( + resource_type: RBACResourceScope, tenant_id: str, path_args: dict[str, object] | None = None +) -> str: """Extract the resource ID from matched path arguments. Some legacy route classes use neutral names such as ``resource_id`` for - app/dataset resources, and Agent App routes use ``agent_id`` as the app id. + app/dataset resources, and Agent routes carry ``agent_id``, which is + resolved to the App backing that Agent. Dataset endpoints behind a rag-pipeline route contain ``pipeline_id`` instead of ``dataset_id``. In that case we look up the associated ``Dataset`` row via ``Dataset.pipeline_id``. @@ -146,10 +150,19 @@ def _extract_resource_id(resource_type: RBACResourceScope, path_args: dict[str, matched_args = {**view_args, **(path_args or {})} if resource_type == RBACResourceScope.APP: - app_id = matched_args.get("app_id") or matched_args.get("agent_id") or matched_args.get("resource_id") - if not app_id: - raise ValueError("Missing app_id in request path") - return str(app_id) + app_id = matched_args.get("app_id") + if app_id: + return str(app_id) + + agent_id = matched_args.get("agent_id") + if agent_id: + authz_app_id = AgentRosterService(db.session).peek_authz_app_id(tenant_id=tenant_id, agent_id=str(agent_id)) + return authz_app_id or str(agent_id) + + resource_id = matched_args.get("resource_id") + if resource_id: + return str(resource_id) + raise ValueError("Missing app_id in request path") if resource_type == RBACResourceScope.DATASET: dataset_id = matched_args.get("dataset_id") or matched_args.get("resource_id") diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py index ef860d86b8f..c48818519ab 100644 --- a/api/controllers/console/agent/composer.py +++ b/api/controllers/console/agent/composer.py @@ -230,6 +230,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource): @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) @with_current_user_id @with_current_tenant_id @with_session @@ -439,6 +440,7 @@ class SnippetAgentComposerSaveToRosterApi(Resource): @rbac_permission_required( RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False ) + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user_id @with_current_tenant_id @with_session @@ -478,6 +480,7 @@ class AgentComposerApi(Resource): @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) @with_current_user_id @with_current_tenant_id @with_session diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 6b0db7a0b48..62393d3bdc4 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -552,6 +552,7 @@ class AgentAppListApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -589,6 +590,7 @@ class AgentAppListApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -630,6 +632,7 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -655,6 +658,7 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_tenant_id @with_session def delete(self, session: Session, tenant_id: str, agent_id: UUID): @@ -712,6 +716,7 @@ class AgentPublishApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -734,6 +739,7 @@ class AgentBuildDraftCheckoutApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -810,6 +816,7 @@ class AgentBuildDraftApplyApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -832,6 +839,7 @@ class AgentAppCopyApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session @@ -857,6 +865,7 @@ class AgentApiAccessApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_tenant_id @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID): @@ -873,6 +882,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) @with_current_tenant_id @with_session @@ -891,6 +901,7 @@ 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) @with_current_tenant_id @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]: @@ -901,6 +912,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) @with_session def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]: @@ -920,6 +932,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) @with_session def delete( @@ -965,6 +978,7 @@ class AgentLogsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session(write=False) @@ -1003,6 +1017,7 @@ class AgentLogMessagesApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session(write=False) @@ -1041,6 +1056,7 @@ class AgentLogSourcesApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session(write=False) @@ -1061,6 +1077,7 @@ class AgentStatisticsSummaryApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session(write=False) @@ -1086,6 +1103,7 @@ class AgentRosterVersionsApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_tenant_id @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID): @@ -1101,6 +1119,7 @@ class AgentRosterVersionDetailApi(Resource): @setup_required @login_required @account_initialization_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_tenant_id @with_session(write=False) def get(self, session: Session, tenant_id: str, agent_id: UUID, version_id: UUID): @@ -1121,6 +1140,7 @@ class AgentRosterVersionRestoreApi(Resource): @login_required @account_initialization_required @edit_permission_required + @rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False) @with_current_user @with_current_tenant_id @with_session diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 41a81267ff9..01df6af5cb9 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -12,6 +12,7 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.common.session import with_session +from controllers.console.app.wraps import agent_manage_required_for_agent_app from fields.base import ResponseModel from libs.helper import dump_response, to_timestamp from libs.login import login_required @@ -194,6 +195,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @console_ns.doc(params={"resource_id": "App ID"}) @console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) @with_current_tenant_id + @agent_manage_required_for_agent_app @with_session(write=False) def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]: """Get all API keys for an app""" @@ -210,6 +212,7 @@ class AppApiKeyListResource(BaseApiKeyListResource): @with_current_tenant_id @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @with_session def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]: """Create a new API key for an app""" @@ -233,6 +236,7 @@ class AppApiKeyResource(BaseApiKeyResource): @with_current_user @with_current_tenant_id @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @with_session def delete( self, diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index a286768d380..701fa366d81 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -9,7 +9,7 @@ from flask_restx import Resource from pydantic import AliasChoices, BaseModel, Field, ValidationInfo, computed_field, field_validator, model_validator from sqlalchemy import select from sqlalchemy.orm import Session -from werkzeug.exceptions import BadRequest, NotFound +from werkzeug.exceptions import BadRequest, Forbidden, NotFound from configs import dify_config from controllers.common.app_access import resolve_app_access_filter @@ -23,7 +23,7 @@ from controllers.common.schema import ( register_schema_models, ) from controllers.console import console_ns -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.workspace.models import LoadBalancingPayload from controllers.console.wraps import ( RBACPermission, @@ -75,6 +75,7 @@ from services.entities.knowledge_entities.knowledge_entities import ( WeightModel, WeightVectorSetting, ) +from services.errors.account import NoPermissionError from services.feature_service import FeatureService from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task @@ -827,6 +828,7 @@ class AppApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @agent_manage_required_for_agent_app @with_session @get_app_model(mode=None) def put(self, session: Session, app_model: App): @@ -861,6 +863,7 @@ class AppApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE) + @agent_manage_required_for_agent_app @with_session @get_app_model def delete(self, session: Session, app_model: App): @@ -885,6 +888,7 @@ class AppCopyApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_CREATE_AND_MANAGEMENT) + @agent_manage_required_for_agent_app @with_current_user @with_current_tenant_id @get_app_model(mode=None) @@ -896,16 +900,19 @@ class AppCopyApi(Resource): with Session(db.engine, expire_on_commit=False) as session: import_service = AppDslService(session) yaml_content = import_service.export_dsl(app_model=app_model, session=session, include_secret=True) - result = import_service.import_app( - account=current_user, - import_mode=ImportMode.YAML_CONTENT, - yaml_content=yaml_content, - name=args.name, - description=args.description, - icon_type=args.icon_type, - icon=args.icon, - icon_background=args.icon_background, - ) + try: + result = import_service.import_app( + account=current_user, + import_mode=ImportMode.YAML_CONTENT, + yaml_content=yaml_content, + name=args.name, + description=args.description, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + ) + except NoPermissionError as e: + raise Forbidden(str(e)) if result.status == ImportStatus.FAILED: session.rollback() return dump_response(AppImportResponse, result), 400 @@ -959,6 +966,7 @@ class AppExportApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) + @agent_manage_required_for_agent_app @get_app_model def get(self, app_model: App): """Export app""" @@ -983,6 +991,7 @@ class AppPublishToCreatorsPlatformApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL) + @agent_manage_required_for_agent_app @with_current_user_id @get_app_model(mode=None) def post(self, current_user_id: str, app_model: App): @@ -1013,6 +1022,7 @@ class AppNameApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @agent_manage_required_for_agent_app @with_session @get_app_model(mode=None) def post(self, session: Session, app_model: App): @@ -1040,6 +1050,7 @@ class AppIconApi(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT) + @agent_manage_required_for_agent_app @with_session @get_app_model(mode=None) def post(self, session: Session, app_model: App): @@ -1073,6 +1084,7 @@ class AppSiteStatus(Resource): @account_initialization_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @with_session @get_app_model(mode=None) def post(self, session: Session, app_model: App): @@ -1100,6 +1112,7 @@ class AppApiStatus(Resource): @is_admin_or_owner_required @account_initialization_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @with_session @get_app_model(mode=None) def post(self, session: Session, app_model: App): diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py index d58c5df1e96..c03e738bf8a 100644 --- a/api/controllers/console/app/app_import.py +++ b/api/controllers/console/app/app_import.py @@ -1,6 +1,7 @@ from flask_restx import Resource from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.common.schema import register_enum_models, register_schema_models @@ -28,6 +29,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 .. import console_ns @@ -91,18 +93,21 @@ class AppImportApi(Resource): import_service = AppDslService(session) # Import app account = current_user - result = import_service.import_app( - account=account, - import_mode=args.mode, - yaml_content=args.yaml_content, - yaml_url=args.yaml_url, - name=args.name, - description=args.description, - icon_type=args.icon_type, - icon=args.icon, - icon_background=args.icon_background, - app_id=args.app_id, - ) + try: + result = import_service.import_app( + account=account, + import_mode=args.mode, + yaml_content=args.yaml_content, + yaml_url=args.yaml_url, + name=args.name, + description=args.description, + icon_type=args.icon_type, + icon=args.icon, + icon_background=args.icon_background, + app_id=args.app_id, + ) + except NoPermissionError as e: + raise Forbidden(str(e)) if result.status == ImportStatus.FAILED: session.rollback() else: @@ -157,7 +162,10 @@ class AppImportConfirmApi(Resource): import_service = AppDslService(session) # Confirm import account = current_user - result = import_service.confirm_import(import_id=import_id, account=account) + try: + result = import_service.confirm_import(import_id=import_id, account=account) + except NoPermissionError as e: + raise Forbidden(str(e)) if result.status == ImportStatus.FAILED: session.rollback() else: diff --git a/api/controllers/console/app/site.py b/api/controllers/console/app/site.py index 64228c247d5..669c59e6d53 100644 --- a/api/controllers/console/app/site.py +++ b/api/controllers/console/app/site.py @@ -10,7 +10,7 @@ from constants.languages import supported_language from controllers.common.schema import 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, @@ -93,6 +93,7 @@ class AppSite(Resource): @login_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @account_initialization_required @with_current_user @with_session @@ -145,6 +146,7 @@ class AppSiteAccessTokenReset(Resource): @login_required @is_admin_or_owner_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION) + @agent_manage_required_for_agent_app @account_initialization_required @with_current_user @with_session diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py index 48021ba18f6..04047f86c8b 100644 --- a/api/controllers/console/app/wraps.py +++ b/api/controllers/console/app/wraps.py @@ -12,14 +12,22 @@ from typing import cast, overload from sqlalchemy import select from sqlalchemy.orm import Session +from configs import dify_config from controllers.common.session import with_session +from controllers.common.wraps import RBACPermission, RBACResourceScope, enforce_rbac_access from controllers.console.app.error import AppNotFoundError from extensions.ext_database import db from libs.login import current_account_with_tenant from models import App, AppMode, TrialApp +from models.agent import AgentScope from services.recommended_app_service import RecommendedAppService -__all__ = ["get_app_model", "get_app_model_with_trial", "with_session"] +__all__ = [ + "agent_manage_required_for_agent_app", + "get_app_model", + "get_app_model_with_trial", + "with_session", +] def _load_app_model(session: Session, app_id: str) -> App | None: @@ -48,6 +56,45 @@ def _load_app_model_with_trial(session: Session, app_id: str) -> App | None: return app_model +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. + + 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 + ) + 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 decorated + + def _get_injected_session(args: tuple[object, ...]) -> Session | None: """Return the request session inserted by `with_session`, if this handler has been migrated.""" if len(args) < 2: diff --git a/api/core/rbac/entities.py b/api/core/rbac/entities.py index 200c33e9424..6296a0a5700 100644 --- a/api/core/rbac/entities.py +++ b/api/core/rbac/entities.py @@ -61,6 +61,7 @@ class RBACPermission(StrEnum): WORKSPACE_ROLE_MANAGE = "workspace_role_manage" API_EXTENSION_MANAGE = "api_extension_manage" CUSTOMIZATION_MANAGE = "customization_manage" + AGENT_MANAGE = "agent_manage" SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify" SNIPPETS_MANAGE = "snippets_management" diff --git a/api/models/model.py b/api/models/model.py index fa74e9b5ead..c9f27a78b91 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -56,6 +56,7 @@ from .provider_ids import GenericProviderID from .types import EnumText, LongText, StringUUID if TYPE_CHECKING: + from .agent import Agent from .workflow import Workflow @@ -501,25 +502,42 @@ class App(Base): Resolved via ``Agent.app_id`` so the console can open the Composer in roster-detail mode from the app id. ``None`` for non-agent apps. """ + agent = self.agent_app_binding_with_session(session=session) + return agent.id if agent else None + + def agent_app_binding_with_session(self, *, session: Session, include_archived: bool = False) -> Agent | None: + """For an Agent App (mode=agent), the Agent bound to it. + + A roster Agent is bound through ``Agent.app_id``; a workflow-only Agent + is bound to its hidden runtime backing App through + ``Agent.backing_app_id``. Callers branch on ``Agent.scope`` to tell the + public roster Agent App apart from the hidden backing App. Archived + Agents are excluded unless ``include_archived`` is set (authorization + gates must keep covering an Agent App after its Agent is archived). + ``None`` for non-agent apps and unbound agent apps. + """ if self.mode != AppMode.AGENT: return None from .agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus - agent = session.scalar( - select(Agent).where( - Agent.tenant_id == self.tenant_id, - sa.or_( - sa.and_( - Agent.app_id == self.id, - Agent.scope == AgentScope.ROSTER, - Agent.source.in_(APP_BACKED_AGENT_SOURCES), - ), - Agent.backing_app_id == self.id, + conditions = [ + Agent.tenant_id == self.tenant_id, + sa.or_( + sa.and_( + Agent.app_id == self.id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), ), - Agent.status == AgentStatus.ACTIVE, - ) - ) - return agent.id if agent else None + sa.and_( + Agent.backing_app_id == self.id, + Agent.scope == AgentScope.WORKFLOW_ONLY, + ), + ), + ] + if not include_archived: + conditions.append(Agent.status == AgentStatus.ACTIVE) + + return session.scalar(select(Agent).where(*conditions).limit(1)) @property def api_base_url(self) -> str: diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 3d9401facf3..979cffafc64 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -911,16 +911,14 @@ class AgentRosterService: raise AgentNotFoundError() return app - def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App: - """Resolve the App that backs an Agent runtime surface. + def _get_runtime_resolvable_agent(self, *, tenant_id: str, agent_id: str) -> Agent | None: + """Load an Agent that is eligible to resolve to a runtime backing App. - Roster Agents use their public Agent App. Workflow-only Agents use a - hidden Agent App stored in ``backing_app_id`` so console chat/logs can - reuse the app runtime without exposing the resource in workspace app - lists. + Shared by the runtime resolver and the read-only authorization resolver + so both agree on what counts as a resolvable Agent. """ - agent = self._session.scalar( + return self._session.scalar( select(Agent) .where( Agent.tenant_id == tenant_id, @@ -938,6 +936,34 @@ class AgentRosterService: ) .limit(1) ) + + def peek_authz_app_id(self, *, tenant_id: str, agent_id: str) -> str | None: + """Resolve the App id whose access policy governs an Agent. + + Roster Agents are governed by their own Agent App, while workflow-only + Agents are governed by their parent workflow App: the hidden runtime + backing App never receives a resource access policy, so it must not be + used for authorization. Stays read-only — unlike + :meth:`get_agent_runtime_app_model`, this never materializes the hidden + backing App. Returns ``None`` when the Agent does not resolve, leaving + the caller to decide how to treat it. + """ + + agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id) + if agent is None: + return None + return agent.app_id + + def get_agent_runtime_app_model(self, *, tenant_id: str, agent_id: str) -> App: + """Resolve the App that backs an Agent runtime surface. + + Roster Agents use their public Agent App. Workflow-only Agents use a + hidden Agent App stored in ``backing_app_id`` so console chat/logs can + reuse the app runtime without exposing the resource in workspace app + lists. + """ + + agent = self._get_runtime_resolvable_agent(tenant_id=tenant_id, agent_id=agent_id) if agent is None: raise AgentNotFoundError() should_commit_backing_app = agent.scope == AgentScope.WORKFLOW_ONLY and not agent.backing_app_id diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 413cc53cc50..8f41ca109fd 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -19,6 +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.trigger.constants import ( TRIGGER_PLUGIN_NODE_TYPE, TRIGGER_SCHEDULE_NODE_TYPE, @@ -43,7 +44,9 @@ from services.agent.dsl_service import AgentDslService, AgentPackage from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.dsl_content import DSL_MAX_SIZE, dsl_content_size from services.dsl_version import check_version_compatibility +from services.enterprise.rbac_service import RBACService from services.entities.dsl_entities import CheckDependenciesResult, DslImportWarning, ImportMode, ImportStatus +from services.errors.account import NoPermissionError from services.errors.app import WorkflowNotFoundError from services.plugin.dependencies_analysis import DependenciesAnalysisService from services.workflow_draft_variable_service import WorkflowDraftVariableService @@ -301,6 +304,9 @@ class AppDslService: error=f"Invalid YAML format: {str(e)}", ) + except NoPermissionError: + raise + except Exception as e: logger.exception("Failed to import app") return Import( @@ -364,6 +370,9 @@ class AppDslService: warnings=self._warnings, ) + except NoPermissionError: + raise + except Exception as e: logger.exception("Error confirming import") return Import( @@ -395,6 +404,21 @@ class AppDslService: leaked_dependencies=leaked_dependencies, ) + @staticmethod + def _ensure_agent_manage_permission(account: Account) -> None: + """Importing an Agent DSL creates a roster Agent, which requires ``agent.manage``.""" + if not dify_config.RBAC_ENABLED: + return + if account.current_tenant_id is None: + raise ValueError("Current tenant is not set") + allowed = RBACService.CheckAccess.check( + account.current_tenant_id, + account.id, + scene=RBACPermission.AGENT_MANAGE, + ) + if not allowed: + raise NoPermissionError("Agent management permission is required to import an Agent App") + def _create_or_update_app( self, *, @@ -415,6 +439,8 @@ class AppDslService: if not app_mode: raise ValueError("loss app mode") app_mode = AppMode(app_mode) + if app_mode == AppMode.AGENT: + self._ensure_agent_manage_permission(account) # Set icon type icon_type_value = icon_type or app_data.get("icon_type") diff --git a/api/services/enterprise/rbac_service.py b/api/services/enterprise/rbac_service.py index 5e318f3ff18..8a6842538ee 100644 --- a/api/services/enterprise/rbac_service.py +++ b/api/services/enterprise/rbac_service.py @@ -330,6 +330,7 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [ "snippets.management", "tool.manage", "mcp.manage", + "agent.manage", ] _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ @@ -357,6 +358,7 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [ "snippets.management", "tool.manage", "mcp.manage", + "agent.manage", ] _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ @@ -371,6 +373,7 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [ "dataset.external.connect", "snippets.create_and_modify", "tool.manage", + "agent.manage", ] _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [ diff --git a/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py new file mode 100644 index 00000000000..4d627354908 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_agent_manage_guard.py @@ -0,0 +1,150 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from werkzeug.exceptions import Forbidden + +from controllers.console.app.error import AppNotFoundError +from controllers.console.app.wraps import agent_manage_required_for_agent_app +from core.rbac import RBACPermission, RBACResourceScope +from models.agent import AgentScope + +TENANT_ID = "tenant-1" +ACCOUNT = SimpleNamespace(id="account-1") + + +def _guarded_view(): + calls: list[dict[str, object]] = [] + + @agent_manage_required_for_agent_app + def view(*args, **kwargs): + calls.append(kwargs) + return "ok" + + return view, calls + + +def _app_with_binding(binding): + app_model = MagicMock() + app_model.agent_app_binding_with_session.return_value = binding + return app_model + + +def _patch_guard(app_model, rbac_enabled: bool): + mock_db = MagicMock() + mock_db.session.scalar.return_value = app_model + return ( + patch("controllers.console.app.wraps.db", mock_db), + patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(ACCOUNT, TENANT_ID)), + patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled), + ) + + +class TestAgentManageRequiredForAgentApp: + def test_non_agent_app_passes_through_without_workspace_check(self): + view, calls = _guarded_view() + patches = _patch_guard(_app_with_binding(None), rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + assert view(app_id="app-1") == "ok" + + gate.assert_not_called() + assert calls == [{"app_id": "app-1"}] + + def test_roster_agent_app_requires_agent_manage_when_rbac_enabled(self): + view, _ = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.ROSTER) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + assert view(app_id="app-1") == "ok" + + gate.assert_called_once_with( + tenant_id=TENANT_ID, + account_id=ACCOUNT.id, + resource_type=RBACResourceScope.WORKSPACE, + scene=RBACPermission.AGENT_MANAGE, + resource_required=False, + ) + + def test_roster_agent_app_denied_without_agent_manage(self): + view, calls = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.ROSTER) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True) + + with ( + patches[0], + patches[1], + patches[2], + patch("controllers.console.app.wraps.enforce_rbac_access", side_effect=Forbidden()), + ): + with pytest.raises(Forbidden): + view(app_id="app-1") + + assert calls == [] + + def test_roster_agent_app_skips_workspace_check_when_rbac_disabled(self): + view, _ = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.ROSTER) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + assert view(app_id="app-1") == "ok" + + gate.assert_not_called() + + def test_hidden_backing_app_is_rejected_even_without_rbac(self): + """A workflow-only backing App is not part of the general app management plane.""" + view, calls = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=False) + + with patches[0], patches[1], patches[2]: + with pytest.raises(AppNotFoundError): + view(app_id="app-1") + + assert calls == [] + + def test_hidden_backing_app_is_rejected_before_workspace_check(self): + view, calls = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.WORKFLOW_ONLY) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + with pytest.raises(AppNotFoundError): + view(app_id="app-1") + + gate.assert_not_called() + assert calls == [] + + def test_binding_lookup_covers_archived_agents(self): + """An Agent App stays gated after its roster Agent is archived.""" + view, _ = _guarded_view() + app_model = _app_with_binding(SimpleNamespace(scope=AgentScope.ROSTER)) + patches = _patch_guard(app_model, rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access"): + view(app_id="app-1") + + _, call_kwargs = app_model.agent_app_binding_with_session.call_args + assert call_kwargs["include_archived"] is True + + def test_resource_id_path_alias_is_resolved(self): + view, _ = _guarded_view() + binding = SimpleNamespace(scope=AgentScope.ROSTER) + patches = _patch_guard(_app_with_binding(binding), rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + assert view(resource_id="app-1") == "ok" + + gate.assert_called_once() + + def test_unknown_app_passes_through_for_downstream_handling(self): + view, calls = _guarded_view() + patches = _patch_guard(None, rbac_enabled=True) + + with patches[0], patches[1], patches[2], patch("controllers.console.app.wraps.enforce_rbac_access") as gate: + assert view(app_id="app-1") == "ok" + + gate.assert_not_called() + assert calls == [{"app_id": "app-1"}] diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 995877a2507..3c19bba2a93 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -201,7 +201,7 @@ class TestRbacPermissionRequired: ): assert protected_view(app_id="app-123") == "ok" - mock_extract.assert_called_once_with("app", {"app_id": "app-123"}) + mock_extract.assert_called_once_with(RBACResourceScope.APP, "tenant-1", {"app_id": "app-123"}) mock_owned.assert_called_once_with("tenant-1", "account-1", "app", "app-123") mock_check.assert_called_once_with( "tenant-1", @@ -307,7 +307,7 @@ class TestRbacPermissionRequired: with app.test_request_context("/"): request.view_args = {"app_id": "view-app"} - assert _extract_resource_id("app", {"app_id": "path-app"}) == "path-app" + assert _extract_resource_id("app", "tenant-1", {"app_id": "path-app"}) == "path-app" def test_extract_resource_id_falls_back_to_request_view_args(self): app = Flask(__name__) @@ -315,22 +315,59 @@ class TestRbacPermissionRequired: with app.test_request_context("/"): request.view_args = {"app_id": "view-app"} - assert _extract_resource_id("app") == "view-app" + assert _extract_resource_id("app", "tenant-1") == "view-app" def test_extract_resource_id_supports_legacy_route_aliases(self): app = Flask(__name__) with app.test_request_context("/apps/app-1/api-keys"): request.view_args = {"resource_id": "app-1"} - assert _extract_resource_id(RBACResourceScope.APP) == "app-1" - - with app.test_request_context("/agent/agent-1/features"): - request.view_args = {"agent_id": "agent-1"} - assert _extract_resource_id(RBACResourceScope.APP) == "agent-1" + assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "app-1" with app.test_request_context("/datasets/dataset-1/api-keys"): request.view_args = {"resource_id": "dataset-1"} - assert _extract_resource_id(RBACResourceScope.DATASET) == "dataset-1" + assert _extract_resource_id(RBACResourceScope.DATASET, "tenant-1") == "dataset-1" + + def test_extract_resource_id_resolves_agent_to_its_authz_app(self): + app = Flask(__name__) + + with ( + app.test_request_context("/agent/agent-1/chat-messages"), + patch("controllers.common.wraps.AgentRosterService") as mock_service, + ): + request.view_args = {"agent_id": "agent-1"} + mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1" + + assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "parent-app-1" + + def test_extract_resource_id_scopes_agent_resolution_to_the_calling_tenant(self): + """The tenant must reach the resolver, or an Agent id from any tenant resolves.""" + app = Flask(__name__) + + with ( + app.test_request_context("/agent/agent-1/chat-messages"), + patch("controllers.common.wraps.AgentRosterService") as mock_service, + ): + request.view_args = {"agent_id": "agent-1"} + mock_service.return_value.peek_authz_app_id.return_value = "parent-app-1" + + _extract_resource_id(RBACResourceScope.APP, "tenant-9") + + mock_service.return_value.peek_authz_app_id.assert_called_once_with( + tenant_id="tenant-9", agent_id="agent-1" + ) + + def test_extract_resource_id_keeps_agent_id_when_the_agent_does_not_resolve(self): + app = Flask(__name__) + + with ( + app.test_request_context("/agent/agent-1/chat-messages"), + patch("controllers.common.wraps.AgentRosterService") as mock_service, + ): + request.view_args = {"agent_id": "agent-1"} + mock_service.return_value.peek_authz_app_id.return_value = None + + assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "agent-1" def test_legacy_admin_decorator_noops_when_rbac_enabled(self): @is_admin_or_owner_required diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index 06f42b47542..0ff248feb0f 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -135,6 +135,38 @@ def test_get_published_agent_soul_for_app_returns_none_without_backing_agent(): assert result is None +def test_peek_authz_app_id_uses_the_parent_app_not_the_hidden_backing_app(): + """A workflow-only Agent is authorized against its parent workflow App.""" + agent = SimpleNamespace(id="agent-1", backing_app_id="backing-app-1", app_id="parent-app-1") + service = AgentRosterService(FakeSession(scalar=[agent])) + + result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") + + assert result == "parent-app-1" + + +def test_peek_authz_app_id_uses_the_roster_agent_app(): + agent = SimpleNamespace(id="agent-1", backing_app_id=None, app_id="roster-app-1") + service = AgentRosterService(FakeSession(scalar=[agent])) + + result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") + + assert result == "roster-app-1" + + +def test_peek_authz_app_id_returns_none_without_creating_a_backing_app(): + """Authorization checks must not materialize the hidden backing App.""" + session = FakeSession(scalar=[None]) + service = AgentRosterService(session) + + result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") + + assert result is None + assert session.added == [] + assert session.commits == 0 + assert session.flushes == 0 + + def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch): session = FakeSession() monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) diff --git a/api/tests/unit_tests/services/enterprise/test_rbac_service.py b/api/tests/unit_tests/services/enterprise/test_rbac_service.py index 27f240797c1..96bd81aec6b 100644 --- a/api/tests/unit_tests/services/enterprise/test_rbac_service.py +++ b/api/tests/unit_tests/services/enterprise/test_rbac_service.py @@ -933,3 +933,20 @@ class TestListOption: "page_number": 1, "resource_type": "app", } + + +class TestLegacyAgentManageKey: + def test_legacy_agent_manage_key_membership(self): + # Mirrors the builtin roles in the rbac service, which grant agent.manage + # to owner/admin/editor only. + for keys in ( + svc._LEGACY_WORKSPACE_OWNER_KEYS, + svc._LEGACY_WORKSPACE_ADMIN_KEYS, + svc._LEGACY_WORKSPACE_EDITOR_KEYS, + ): + assert "agent.manage" in keys + for keys in ( + svc._LEGACY_WORKSPACE_NORMAL_KEYS, + svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS, + ): + assert "agent.manage" not in keys 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 d80cb12d83e..534fd41a33b 100644 --- a/api/tests/unit_tests/services/test_app_dsl_service.py +++ b/api/tests/unit_tests/services/test_app_dsl_service.py @@ -5,10 +5,12 @@ from unittest.mock import Mock import pytest from sqlalchemy.orm import Session +from core.rbac import RBACPermission from models import App, AppMode from models.model import AppModelConfig, IconType from services.app_dsl_service import AppDslService from services.entities.dsl_entities import ImportStatus +from services.errors.account import NoPermissionError @pytest.mark.parametrize("sqlite_session", [()], indirect=True) @@ -166,3 +168,61 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session session.get.assert_called_once_with(AppModelConfig, "config-1") load_annotation_reply_config.assert_called_once_with(session, "app-1") app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply) + + +def test_ensure_agent_manage_permission_noops_when_rbac_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", False) + check = Mock() + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) + + AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + + check.assert_not_called() + + +def test_ensure_agent_manage_permission_allows_agent_manager(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + check = Mock(return_value=True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check) + + AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + + check.assert_called_once_with("tenant-1", "account-1", scene=RBACPermission.AGENT_MANAGE) + + +def test_ensure_agent_manage_permission_rejects_without_agent_manage(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) + + with pytest.raises(NoPermissionError): + AppDslService._ensure_agent_manage_permission(Mock(id="account-1", current_tenant_id="tenant-1")) + + +def test_create_or_update_app_gates_agent_mode_before_creation(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) + session = Mock() + service = AppDslService(session=session) + + with pytest.raises(NoPermissionError): + service._create_or_update_app( + app=None, + data={"app": {"mode": "agent", "name": "Gated agent"}}, + account=Mock(id="account-1", current_tenant_id="tenant-1"), + ) + + session.add.assert_not_called() + session.flush.assert_not_called() + + +def test_import_app_reraises_permission_denial_instead_of_failed_result(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.app_dsl_service.dify_config.RBAC_ENABLED", True) + monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", Mock(return_value=False)) + service = AppDslService(session=Mock()) + + with pytest.raises(NoPermissionError): + service.import_app( + account=Mock(id="account-1", current_tenant_id="tenant-1"), + import_mode="yaml-content", + yaml_content="app:\n mode: agent\n name: Denied agent\n", + ) diff --git a/web/.env.example b/web/.env.example index 0b1c2498384..64b8082bbfd 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,6 +2,8 @@ NEXT_PUBLIC_DEPLOY_ENV=DEVELOPMENT # The deployment edition, SELF_HOSTED NEXT_PUBLIC_EDITION=SELF_HOSTED +# Whether a self-hosted deployment runs Enterprise Edition +NEXT_PUBLIC_ENTERPRISE_ENABLED=false # The base path for the application NEXT_PUBLIC_BASE_PATH= # Server-only console API origin for server-side requests. diff --git a/web/app/(commonLayout)/agents/__tests__/agents-access-guard.spec.tsx b/web/app/(commonLayout)/agents/__tests__/agents-access-guard.spec.tsx new file mode 100644 index 00000000000..5d626e17278 --- /dev/null +++ b/web/app/(commonLayout)/agents/__tests__/agents-access-guard.spec.tsx @@ -0,0 +1,110 @@ +import { screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render } from '@/test/console/render' +import { AgentsAccessGuard } from '../agents-access-guard' + +const mockReplace = vi.fn() +const mockConsoleStateReader = vi.fn() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + + return createWorkspaceStateModuleMock(() => mockConsoleStateReader()) +}) +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + + return createPermissionStateModuleMock(() => mockConsoleStateReader()) +}) + +type ConsoleStateFixture = { + isLoadingCurrentWorkspace: boolean + isLoadingWorkspacePermissionKeys: boolean + workspacePermissionKeys: string[] + currentWorkspace: { + id: string + } +} + +const baseContext: ConsoleStateFixture = { + isLoadingCurrentWorkspace: false, + isLoadingWorkspacePermissionKeys: false, + workspacePermissionKeys: ['agent.manage'], + currentWorkspace: { + id: 'workspace-1', + }, +} + +const setConsoleState = (overrides: Partial = {}) => { + mockConsoleStateReader.mockReturnValue({ + ...baseContext, + ...overrides, + }) +} + +describe('AgentsAccessGuard', () => { + beforeEach(() => { + vi.clearAllMocks() + setConsoleState() + }) + + it('renders loading while the workspace is loading', () => { + setConsoleState({ isLoadingCurrentWorkspace: true, currentWorkspace: { id: '' } }) + + render( + +
agents
+
, + ) + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('agents')).not.toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('renders loading while workspace permission keys are loading', () => { + setConsoleState({ isLoadingWorkspacePermissionKeys: true, workspacePermissionKeys: [] }) + + render( + +
agents
+
, + ) + + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('agents')).not.toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) + + it('redirects to /apps without agent.manage', async () => { + setConsoleState({ workspacePermissionKeys: ['dataset.create_and_management'] }) + + render( + +
agents
+
, + ) + + expect(screen.queryByText('agents')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/') + }) + }) + + it('renders children with agent.manage', () => { + render( + +
agents
+
, + ) + + expect(screen.getByText('agents')).toBeInTheDocument() + expect(mockReplace).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx index 0f7601244af..8bcf8a44b5b 100644 --- a/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx +++ b/web/app/(commonLayout)/agents/__tests__/layout.spec.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import { render, screen } from '@testing-library/react' const mocks = vi.hoisted(() => ({ @@ -8,6 +9,12 @@ vi.mock('../feature-guard', () => ({ guardAgentV2Route: () => mocks.guardAgentV2Route(), })) +// Access control is covered by agents-access-guard.spec.tsx; this suite is +// about the feature-flag guard only. +vi.mock('../agents-access-guard', () => ({ + AgentsAccessGuard: ({ children }: { children: ReactNode }) => <>{children}, +})) + describe('RosterLayout', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/app/(commonLayout)/agents/agents-access-guard.tsx b/web/app/(commonLayout)/agents/agents-access-guard.tsx new file mode 100644 index 00000000000..5735a24a349 --- /dev/null +++ b/web/app/(commonLayout)/agents/agents-access-guard.tsx @@ -0,0 +1,30 @@ +'use client' + +import type { ReactNode } from 'react' +import { useAtomValue } from 'jotai' +import { useEffect } from 'react' +import Loading from '@/app/components/base/loading' +import { workspacePermissionKeysLoadingAtom } from '@/context/permission-state' +import { currentWorkspaceIdAtom, currentWorkspaceLoadingAtom } from '@/context/workspace-state' +import { useCanManageAgents } from '@/features/agent-v2/permissions' +import { useRouter } from '@/next/navigation' + +export function AgentsAccessGuard({ children }: { children: ReactNode }) { + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) + const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) + const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) + const canManageAgents = useCanManageAgents() + const router = useRouter() + const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys + const shouldRedirect = !isLoadingAccess && !!currentWorkspaceId && !canManageAgents + + useEffect(() => { + if (shouldRedirect) router.replace('/') + }, [shouldRedirect, router]) + + if (isLoadingAccess || !currentWorkspaceId) return + + if (shouldRedirect) return null + + return children +} diff --git a/web/app/(commonLayout)/agents/layout.tsx b/web/app/(commonLayout)/agents/layout.tsx index f707fe9d14e..5f87a857cb1 100644 --- a/web/app/(commonLayout)/agents/layout.tsx +++ b/web/app/(commonLayout)/agents/layout.tsx @@ -1,8 +1,9 @@ import type { ReactNode } from 'react' +import { AgentsAccessGuard } from './agents-access-guard' import { guardAgentV2Route } from './feature-guard' export default function Layout({ children }: { children: ReactNode }) { guardAgentV2Route() - return children + return {children} } diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index f9b6d6e7a56..c385164cd3a 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -347,6 +347,7 @@ const ownerWorkspacePermissionKeys = [ 'dataset.external.connect', 'tool.manage', 'mcp.manage', + 'agent.manage', ] const datasetOperatorWorkspacePermissionKeys = [ @@ -567,6 +568,23 @@ describe('MainNav', () => { expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) + it('hides the roster entry when the user lacks agent.manage', () => { + mockConsoleState.current = { + ...consoleState, + workspacePermissionKeys: ownerWorkspacePermissionKeys.filter((key) => key !== 'agent.manage'), + } + + renderMainNav() + + expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() + }) + + it('shows the roster entry when the user has agent.manage', () => { + renderMainNav() + + expect(screen.getByRole('link', { name: /Agents/ })).toBeInTheDocument() + }) + it('hides the marketplace entry when marketplace is disabled', () => { renderMainNav({ enable_marketplace: false }) @@ -721,7 +739,7 @@ describe('MainNav', () => { isCurrentWorkspaceEditor: false, isCurrentWorkspaceManager: false, isCurrentWorkspaceOwner: false, - workspacePermissionKeys: ['app_library.access', 'tool.manage'], + workspacePermissionKeys: ['app_library.access', 'tool.manage', 'agent.manage'], } renderMainNav({ branding: { enabled: false }, enable_app_deploy: true }) diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 65a4868e67b..45127955827 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -16,6 +16,7 @@ import { isCurrentWorkspaceEditorAtom, } from '@/context/workspace-state' import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag' +import { useCanManageAgents } from '@/features/agent-v2/permissions' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import dynamic from '@/next/dynamic' import Link from '@/next/link' @@ -37,6 +38,7 @@ export function MainNav({ className }: MainNavProps) { const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const agentV2Enabled = isAgentV2Enabled() + const canManageAgents = useCanManageAgents() const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' @@ -47,6 +49,7 @@ export function MainNav({ className }: MainNavProps) { MAIN_NAV_ROUTES.filter((route) => isMainNavRouteVisible(route, { agentV2Enabled, + canManageAgents, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, marketplaceEnabled: systemFeatures.enable_marketplace, @@ -60,6 +63,7 @@ export function MainNav({ className }: MainNavProps) { })), [ agentV2Enabled, + canManageAgents, canUseAppDeploy, isCurrentWorkspaceDatasetOperator, systemFeatures.enable_marketplace, diff --git a/web/app/components/main-nav/routes.ts b/web/app/components/main-nav/routes.ts index a13d3617a0e..936e8d28c3c 100644 --- a/web/app/components/main-nav/routes.ts +++ b/web/app/components/main-nav/routes.ts @@ -1,6 +1,6 @@ import { buildIntegrationPath } from '@/app/components/integrations/routes' -type MainNavRouteVisibility = 'all' | 'notDatasetOperator' | 'appDeployEditor' +type MainNavRouteVisibility = (options: MainNavRouteVisibilityOptions) => boolean const DATASET_COLLECTION_ROUTES = new Set(['create', 'create-from-pipeline', 'connect']) const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipeline']) @@ -18,6 +18,7 @@ export type MainNavRouteConfig = { export type MainNavRouteVisibilityOptions = { agentV2Enabled: boolean + canManageAgents: boolean canUseAppDeploy: boolean isCurrentWorkspaceDatasetOperator: boolean marketplaceEnabled: boolean @@ -28,6 +29,10 @@ export type DetailSidebarVisibilityOptions = Pick< 'agentV2Enabled' | 'canUseAppDeploy' | 'isCurrentWorkspaceDatasetOperator' > +const VISIBLE_TO_ALL: MainNavRouteVisibility = () => true +const CAN_MANAGE_AGENTS: MainNavRouteVisibility = (options) => options.canManageAgents +const CAN_USE_APP_DEPLOY: MainNavRouteVisibility = (options) => options.canUseAppDeploy + function isPathUnderRoute(pathname: string, route: string) { return pathname === route || pathname.startsWith(`${route}/`) } @@ -40,7 +45,7 @@ export const MAIN_NAV_ROUTES = [ active: (path: string) => path === '/' || path === '/explore/apps', icon: 'i-custom-vender-main-nav-home', activeIcon: 'i-custom-vender-main-nav-home-active', - visibility: 'all', + visibility: VISIBLE_TO_ALL, }, { key: 'apps', @@ -52,7 +57,7 @@ export const MAIN_NAV_ROUTES = [ isPathUnderRoute(path, '/snippets'), icon: 'i-custom-vender-main-nav-studio', activeIcon: 'i-custom-vender-main-nav-studio-active', - visibility: 'all', + visibility: VISIBLE_TO_ALL, }, { key: 'roster', @@ -61,7 +66,7 @@ export const MAIN_NAV_ROUTES = [ active: (path: string) => isPathUnderRoute(path, '/agents'), icon: 'i-custom-vender-main-nav-roster', activeIcon: 'i-custom-vender-main-nav-roster-active', - visibility: 'notDatasetOperator', + visibility: CAN_MANAGE_AGENTS, feature: 'agentV2', }, { @@ -71,7 +76,7 @@ export const MAIN_NAV_ROUTES = [ active: (path: string) => isPathUnderRoute(path, '/datasets'), icon: 'i-custom-vender-main-nav-knowledge', activeIcon: 'i-custom-vender-main-nav-knowledge-active', - visibility: 'all', + visibility: VISIBLE_TO_ALL, }, { key: 'integrations', @@ -81,7 +86,7 @@ export const MAIN_NAV_ROUTES = [ isPathUnderRoute(path, '/integrations') || isPathUnderRoute(path, '/tools'), icon: 'i-custom-vender-main-nav-integrations', activeIcon: 'i-custom-vender-main-nav-integrations-active', - visibility: 'all', + visibility: VISIBLE_TO_ALL, }, { key: 'marketplace', @@ -91,7 +96,7 @@ export const MAIN_NAV_ROUTES = [ isPathUnderRoute(path, '/marketplace') || isPathUnderRoute(path, '/plugins'), icon: 'i-custom-vender-main-nav-marketplace', activeIcon: 'i-custom-vender-main-nav-marketplace-active', - visibility: 'all', + visibility: VISIBLE_TO_ALL, feature: 'marketplace', }, { @@ -101,7 +106,7 @@ export const MAIN_NAV_ROUTES = [ active: (path: string) => isPathUnderRoute(path, '/deployments'), icon: 'i-ri-rocket-line', activeIcon: 'i-ri-rocket-fill', - visibility: 'appDeployEditor', + visibility: CAN_USE_APP_DEPLOY, }, ] as const satisfies readonly MainNavRouteConfig[] @@ -113,11 +118,7 @@ export function isMainNavRouteVisible( if (route.feature === 'marketplace' && !options.marketplaceEnabled) return false - if (route.visibility === 'all') return true - - if (route.visibility === 'notDatasetOperator') return !options.isCurrentWorkspaceDatasetOperator - - return options.canUseAppDeploy + return route.visibility(options) } function isAppDetailPathname(pathname: string) { diff --git a/web/app/components/workflow/block-selector/__tests__/agent-selector.spec.tsx b/web/app/components/workflow/block-selector/__tests__/agent-selector.spec.tsx new file mode 100644 index 00000000000..fe3c92af1ef --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/agent-selector.spec.tsx @@ -0,0 +1,90 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { AgentSelectorContent } from '../agent-selector' + +const mocks = vi.hoisted(() => ({ + canManageAgents: true, + agents: [] as Array<{ id: string; name: string }>, +})) + +vi.mock('@/features/agent-v2/permissions', () => ({ + useCanManageAgents: () => mocks.canManageAgents, +})) + +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: () => undefined, +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + agent: { + inviteOptions: { + get: { + queryOptions: () => ({ + queryKey: ['agent-invite-options'], + queryFn: async () => ({ data: mocks.agents }), + }), + }, + }, + }, + }, +})) + +const manageInConsoleLabel = /manageInAgentConsole/ +const startFromScratchLabel = /startFromScratch/ + +const renderSelector = async ({ onStartFromScratch }: { onStartFromScratch?: () => void } = {}) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + , + ) + + await screen.findByRole('listbox') +} + +describe('AgentSelectorContent', () => { + beforeEach(() => { + mocks.canManageAgents = true + mocks.agents = [] + }) + + it('offers the Agent Console link with agent.manage', async () => { + await renderSelector() + + expect(screen.getByText(manageInConsoleLabel)).toBeInTheDocument() + }) + + it('hides the Agent Console link without agent.manage', async () => { + mocks.canManageAgents = false + + await renderSelector() + + expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument() + }) + + it('keeps start from scratch without agent.manage', async () => { + mocks.canManageAgents = false + + await renderSelector({ onStartFromScratch: vi.fn() }) + + expect(screen.getByText(startFromScratchLabel)).toBeInTheDocument() + expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument() + }) + + it('renders no action row when neither action is available', async () => { + mocks.canManageAgents = false + + await renderSelector() + + expect(screen.queryByText(startFromScratchLabel)).not.toBeInTheDocument() + expect(screen.queryByText(manageInConsoleLabel)).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 7322c743cd8..b051023c65a 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -50,6 +50,12 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) +// Permission-dependent selector actions are covered by agent-selector.spec.tsx; +// this suite is about block insertion. +vi.mock('@/features/agent-v2/permissions', () => ({ + useCanManageAgents: () => true, +})) + const createBlock = ( type: BlockEnum, title: string, diff --git a/web/app/components/workflow/block-selector/agent-selector.tsx b/web/app/components/workflow/block-selector/agent-selector.tsx index aff8e9c260a..81115de3a18 100644 --- a/web/app/components/workflow/block-selector/agent-selector.tsx +++ b/web/app/components/workflow/block-selector/agent-selector.tsx @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' import { useHooksStore } from '@/app/components/workflow/hooks-store' +import { useCanManageAgents } from '@/features/agent-v2/permissions' import Link from '@/next/link' import { consoleQuery } from '@/service/client' import BlockIcon from '../block-icon' @@ -60,9 +61,13 @@ export function AgentSelectorContent({ staleTime: 0, }) const agents = agentsQuery.data?.data ?? [] - const actionOptions: AgentSelectorActionOption[] = onStartFromScratch - ? ['start-from-scratch', 'manage-in-agent-console'] - : ['manage-in-agent-console'] + const canManageAgents = useCanManageAgents() + const actionOptions: AgentSelectorActionOption[] = [ + // Start from scratch stays available to everyone: it only writes the node's + // own inline draft and never reaches the Agent Console. + ...(onStartFromScratch ? (['start-from-scratch'] as const) : []), + ...(canManageAgents ? (['manage-in-agent-console'] as const) : []), + ] const options: AgentSelectorOption[] = [...agents, ...actionOptions] const getOptionLabel = (option: AgentSelectorOption) => { if (isAgentSelectorActionOption(option)) { @@ -150,11 +155,13 @@ export function AgentSelectorContent({ )} -
- {actionOptions.map((option) => ( - - ))} -
+ {actionOptions.length > 0 && ( +
+ {actionOptions.map((option) => ( + + ))} +
+ )} diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index 3b191efacdd..aba83d5bc83 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -147,6 +147,10 @@ vi.mock('../../_base/hooks/use-node-crud', () => ({ default: (id: string, data: AgentV2NodeType) => mockUseNodeCrud(id, data), })) +vi.mock('@/features/agent-v2/permissions', () => ({ + useCanManageAgents: () => true, +})) + vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({ AgentSelectorContent: ({ onSelect, diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 7c9841c423b..da6ecce2805 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -22,6 +22,12 @@ const mocks = vi.hoisted(() => ({ uploadWorkflowSandboxFile: vi.fn(), })) +const permission = vi.hoisted(() => ({ canManageAgents: true })) + +vi.mock('@/features/agent-v2/permissions', () => ({ + useCanManageAgents: () => permission.canManageAgents, +})) + vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ useDefaultModel: () => ({ data: undefined, @@ -411,6 +417,7 @@ function createInlineComposerState({ describe('WorkflowInlineAgentConfigureWorkspace', () => { beforeEach(() => { vi.clearAllMocks() + permission.canManageAgents = true mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 })) mocks.checkoutBuildDraft.mockResolvedValue({ agent_soul: {}, @@ -508,6 +515,19 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { ).not.toBeInTheDocument() }) + it('should hide the save-to-roster menu when the user cannot manage agents', async () => { + permission.canManageAgents = false + + renderWorkspace({ + onSaveInlineToRoster: vi.fn(), + }) + + await screen.findByRole('region', { name: 'orchestrate-panel' }) + expect( + screen.queryByRole('button', { name: 'common.operation.more' }), + ).not.toBeInTheDocument() + }) + it('should show the working directory panel when the header action is clicked', async () => { renderWorkspace({ inlineComposerState: createInlineComposerState({ diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx index 13a577cd173..a0ce9fe5bb3 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-roster-field.spec.tsx @@ -3,10 +3,39 @@ import userEvent from '@testing-library/user-event' import { useRef } from 'react' import { AgentRosterField } from '../agent-roster-field' +const permission = vi.hoisted(() => ({ canManageAgents: true })) + +vi.mock('@/features/agent-v2/permissions', () => ({ + useCanManageAgents: () => permission.canManageAgents, +})) + vi.mock('@/app/components/workflow/block-selector/agent-selector', () => ({ AgentSelectorContent: () => null, })) +function renderDetailRosterField() { + function Harness() { + const portalContainerRef = useRef(null) + + return ( +
+ +
+ ) + } + + render() +} + function renderInlineRosterField() { function Harness() { const portalContainerRef = useRef(null) @@ -32,6 +61,37 @@ function renderInlineRosterField() { } describe('AgentRosterField', () => { + beforeEach(() => { + permission.canManageAgents = true + }) + + it('shows Make Copy in the roster detail panel', async () => { + const user = userEvent.setup() + renderDetailRosterField() + + await user.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) + + expect( + await screen.findByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }), + ).toBeInTheDocument() + }) + + it('keeps Make Copy available when the user cannot manage agents', async () => { + permission.canManageAgents = false + const user = userEvent.setup() + renderDetailRosterField() + + await user.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) + + expect( + await screen.findByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }), + ).toBeInTheDocument() + }) + it('returns focus to the inline setup trigger when the dialog closes with Escape', async () => { const user = userEvent.setup() renderInlineRosterField() diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/edit-in-console-link.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/edit-in-console-link.spec.tsx new file mode 100644 index 00000000000..20e1dc5a792 --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/edit-in-console-link.spec.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react' +import { EditInConsoleLink } from '../edit-in-console-link' + +describe('EditInConsoleLink', () => { + it('renders a link to the agent console when permitted', () => { + render() + + const link = screen.getByRole('link', { name: /editInConsole/ }) + expect(link).toHaveAttribute('href', expect.stringContaining('/agents/agent-1')) + }) + + it('renders a disabled control instead of a link when not permitted', () => { + render() + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /editInConsole/ })).toHaveAttribute( + 'aria-disabled', + 'true', + ) + }) +}) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index c7f081c904e..28247caba35 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -62,6 +62,7 @@ import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData, } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft' +import { useCanManageAgents } from '@/features/agent-v2/permissions' import { consoleQuery } from '@/service/client' import { FlowType } from '@/types/common' import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config' @@ -731,6 +732,9 @@ function WorkflowInlineAgentConfigureMoreAction({ onSaveInlineToRoster: () => void }) { const { t } = useTranslation('common') + const canManageAgents = useCanManageAgents() + + if (!canManageAgents) return null return ( diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx index 7fbdc9d97d7..09c2470fe1b 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-roster-field.tsx @@ -31,8 +31,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { AgentSelectorContent } from '@/app/components/workflow/block-selector/agent-selector' -import { getAgentDetailPath } from '@/features/agent-v2/agent-detail/routes' -import Link from '@/next/link' +import { useCanManageAgents } from '@/features/agent-v2/permissions' +import { EditInConsoleLink } from './edit-in-console-link' const i18nPrefix = 'nodes.agent' type AgentRosterDrawerMode = 'setup' | 'detail' @@ -122,6 +122,7 @@ function AgentRosterDrawer({ onClose: () => void }) { const { t } = useTranslation() + const canManageAgents = useCanManageAgents() const isSetup = mode === 'setup' const title = isInlineSetup ? t(($) => $[`${i18nPrefix}.roster.inlineSetup.name`], { ns: 'workflow' }) @@ -129,7 +130,7 @@ function AgentRosterDrawer({ const description = isSetup ? t(($) => $[`${i18nPrefix}.roster.inlineSetup.description`], { ns: 'workflow' }) : agent.role - const showInlineActions = isInlineSetup && !!onSaveInlineToRoster + const showInlineActions = isInlineSetup && !!onSaveInlineToRoster && canManageAgents return ( {showConsoleLink && ( - - - - {t(($) => $[`${i18nPrefix}.roster.editInConsole`], { ns: 'workflow' })} - - + )} + } + /> + + {t(($) => $['nodes.agent.roster.editInConsoleDisabled'], { ns: 'workflow' })} + + + ) +} diff --git a/web/config/__tests__/index.spec.ts b/web/config/__tests__/index.spec.ts new file mode 100644 index 00000000000..81f0df542f0 --- /dev/null +++ b/web/config/__tests__/index.spec.ts @@ -0,0 +1,59 @@ +type DeploymentCase = { + edition: 'CLOUD' | 'SELF_HOSTED' + enterpriseEnabled: boolean + expected: { + isCloud: boolean + isCommunity: boolean + isSelfHosted: boolean + } +} + +const loadConfig = async ({ edition, enterpriseEnabled }: DeploymentCase) => { + vi.resetModules() + vi.doMock('@/env', () => ({ + env: { + NEXT_PUBLIC_EDITION: edition, + NEXT_PUBLIC_ENTERPRISE_ENABLED: enterpriseEnabled, + }, + })) + + return import('../index') +} + +describe('deployment edition config', () => { + afterEach(() => { + vi.doUnmock('@/env') + vi.resetModules() + }) + + it.each([ + { + edition: 'CLOUD', + enterpriseEnabled: false, + expected: { isCloud: true, isCommunity: false, isSelfHosted: false }, + }, + { + edition: 'CLOUD', + enterpriseEnabled: true, + expected: { isCloud: true, isCommunity: false, isSelfHosted: false }, + }, + { + edition: 'SELF_HOSTED', + enterpriseEnabled: false, + expected: { isCloud: false, isCommunity: true, isSelfHosted: true }, + }, + { + edition: 'SELF_HOSTED', + enterpriseEnabled: true, + expected: { isCloud: false, isCommunity: false, isSelfHosted: true }, + }, + ])('derives flags for $edition with enterpriseEnabled=$enterpriseEnabled', async (deployment) => { + const config = await loadConfig(deployment) + + expect({ + isCloud: config.IS_CLOUD_EDITION, + isCommunity: config.IS_COMMUNITY_EDITION, + isSelfHosted: config.IS_CE_EDITION, + }).toEqual(deployment.expected) + }) +}) diff --git a/web/config/index.ts b/web/config/index.ts index 3109e9a6100..26cc9edfe40 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -29,6 +29,7 @@ const EDITION = env.NEXT_PUBLIC_EDITION export const IS_CE_EDITION = EDITION === 'SELF_HOSTED' export const IS_CLOUD_EDITION = EDITION === 'CLOUD' +export const IS_COMMUNITY_EDITION = IS_CE_EDITION && !env.NEXT_PUBLIC_ENTERPRISE_ENABLED export const AMPLITUDE_API_KEY = getStringConfig(env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '') export const COOKIEYES_SITE_KEY = getStringConfig(env.NEXT_PUBLIC_COOKIEYES_SITE_KEY, '') diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index 402a0d3cf59..205748a2aed 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -14,6 +14,7 @@ set -e export NEXT_PUBLIC_DEPLOY_ENV=${DEPLOY_ENV} export NEXT_PUBLIC_EDITION=${EDITION} +export NEXT_PUBLIC_ENTERPRISE_ENABLED=${NEXT_PUBLIC_ENTERPRISE_ENABLED:-${ENTERPRISE_ENABLED}} export NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH} export NEXT_PUBLIC_API_PREFIX=${CONSOLE_API_URL}/console/api export NEXT_PUBLIC_PUBLIC_API_PREFIX=${APP_API_URL}/api diff --git a/web/env.ts b/web/env.ts index 8fe0ecc834f..fdd7e6a0577 100644 --- a/web/env.ts +++ b/web/env.ts @@ -82,6 +82,10 @@ const clientSchema = { * "Go to Anything" command palette (Cmd/Ctrl+K). */ NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(true), + /** + * Whether a self-hosted deployment runs Enterprise Edition. + */ + NEXT_PUBLIC_ENTERPRISE_ENABLED: coercedBoolean.default(false), /** * Cloud-only system-features defaults. @@ -254,6 +258,9 @@ export const env = createEnv({ NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: isServer ? process.env.NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW : getRuntimeEnvFromBody('enableFeaturePreview'), + NEXT_PUBLIC_ENTERPRISE_ENABLED: isServer + ? process.env.NEXT_PUBLIC_ENTERPRISE_ENABLED + : getRuntimeEnvFromBody('enterpriseEnabled'), /** * Cloud-only system-features defaults. diff --git a/web/features/agent-v2/agent-detail/configure/components/__tests__/community-edition-tip.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/__tests__/community-edition-tip.spec.tsx new file mode 100644 index 00000000000..57b64bc79af --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/__tests__/community-edition-tip.spec.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import { CommunityEditionTip } from '../community-edition-tip' + +const edition = vi.hoisted(() => ({ isCommunity: true })) + +vi.mock('@/config', async (importOriginal) => ({ + ...(await importOriginal()), + get IS_COMMUNITY_EDITION() { + return edition.isCommunity + }, +})) + +const tip = 'sandbox runs as a non-root user' + +describe('CommunityEditionTip', () => { + it('shows the warning on community edition (self-hosted, non-enterprise)', () => { + edition.isCommunity = true + + render() + + expect(screen.getByLabelText(tip)).toBeInTheDocument() + }) + + it('renders nothing on an enterprise or cloud deployment', () => { + // Sandbox isolation is a property of the community build, so the tip is + // gated on edition alone — not on license or billing state. + edition.isCommunity = false + + render() + + expect(screen.queryByLabelText(tip)).not.toBeInTheDocument() + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/community-edition-tip.tsx b/web/features/agent-v2/agent-detail/configure/components/community-edition-tip.tsx new file mode 100644 index 00000000000..074faaf1a40 --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/community-edition-tip.tsx @@ -0,0 +1,53 @@ +'use client' + +import type { Placement } from '@langgenius/dify-ui/popover' +import { cn } from '@langgenius/dify-ui/cn' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { IS_COMMUNITY_EDITION } from '@/config' + +type CommunityEditionTipProps = { + tip: string + placement?: Placement + popupClassName?: string +} + +/** + * Warning affordance for caveats that only apply to community edition. + * Renders nothing on enterprise or cloud deployments, so callers do not repeat + * the edition check. + */ +export function CommunityEditionTip({ + tip, + placement = 'bottom', + popupClassName, +}: CommunityEditionTipProps) { + if (!IS_COMMUNITY_EDITION) return null + + return ( + + + + + } + /> + + {tip} + + + ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx index 0632b3c4434..753bd8c681c 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/header.tsx @@ -1,8 +1,8 @@ 'use client' import type { ReactNode } from 'react' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' +import { CommunityEditionTip } from '../community-edition-tip' type AgentOrchestrateHeaderProps = { headingId: string @@ -27,31 +27,7 @@ export function AgentOrchestrateHeader({

{t(($) => $['agentDetail.configure.title'])}

- - - - - } - /> - - {communityEditionIsolationTip} - - + {isBuildDraftActive && ( {t(($) => $['agentDetail.configure.buildDraft.modeBadge'])} diff --git a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx index 1cd8c4c8bfa..e2c1a827898 100644 --- a/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/preview/build-chat.tsx @@ -1,8 +1,8 @@ 'use client' import type { AgentChatRuntimeProps } from './chat-runtime' -import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { useTranslation } from 'react-i18next' +import { CommunityEditionTip } from '../community-edition-tip' import { AgentChatRuntime } from './chat-runtime' const buildIconGridCellOpacities = [ @@ -53,31 +53,11 @@ function AgentBuildChatEmptyState() {
{t(($) => $['agentDetail.configure.build.empty.title'])}
- - - - - } - /> - - {communityEditionBuildModeTip} - - +

{t(($) => $['agentDetail.configure.build.empty.description'])} diff --git a/web/features/agent-v2/permissions.ts b/web/features/agent-v2/permissions.ts new file mode 100644 index 00000000000..7108f395015 --- /dev/null +++ b/web/features/agent-v2/permissions.ts @@ -0,0 +1,13 @@ +'use client' + +import { useAtomValue } from 'jotai' +import { workspacePermissionKeysAtom } from '@/context/permission-state' +import { hasPermission } from '@/utils/permission' + +const AGENT_MANAGE_PERMISSION_KEY = 'agent.manage' + +export const useCanManageAgents = () => { + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) + + return hasPermission(workspacePermissionKeys, AGENT_MANAGE_PERMISSION_KEY) +} diff --git a/web/i18n/ar-TN/permission-keys.json b/web/i18n/ar-TN/permission-keys.json index 67c424784c2..93d4aa39416 100644 --- a/web/i18n/ar-TN/permission-keys.json +++ b/web/i18n/ar-TN/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "إدارة Agents", "api_extension.manage": "إدارة إعدادات امتداد API", "app.access_config": "تكوين أذونات الوصول إلى التطبيق", "app.acl.access_config": "تكوين أذونات الوصول إلى التطبيق", diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 9dad0b64692..377561e16e1 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "تم تثبيت هذا الملحق من GitHub. يرجى الانتقال إلى الملحقات لإعادة التثبيت", "nodes.agent.roster.change": "تغيير", "nodes.agent.roster.editInConsole": "تعديل في Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "ليس لديك إذن لإدارة Agents", "nodes.agent.roster.inlineSetup.description": "إعداد لمرة واحدة يبقى فقط في هذه العقدة", "nodes.agent.roster.inlineSetup.name": "إعداد inline", "nodes.agent.roster.inlineSetup.title": "إعداد الوكيل", diff --git a/web/i18n/de-DE/permission-keys.json b/web/i18n/de-DE/permission-keys.json index e1d2d90a9b0..0d65b4fe9d1 100644 --- a/web/i18n/de-DE/permission-keys.json +++ b/web/i18n/de-DE/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agents verwalten", "api_extension.manage": "API-Erweiterungskonfiguration verwalten", "app.access_config": "App-Zugriffsberechtigungen konfigurieren", "app.acl.access_config": "App-Zugriffsberechtigungen konfigurieren", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index de8028f55d5..41f0e688cd7 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Dieses Integration wird von GitHub installiert. Bitte gehen Sie zu Integrationen, um sie neu zu installieren", "nodes.agent.roster.change": "Wechseln", "nodes.agent.roster.editInConsole": "In Agent Console bearbeiten", + "nodes.agent.roster.editInConsoleDisabled": "Sie haben keine Berechtigung, Agents zu verwalten", "nodes.agent.roster.inlineSetup.description": "Eine einmalige Einrichtung, die nur in diesem Knoten existiert", "nodes.agent.roster.inlineSetup.name": "Inline-Einrichtung", "nodes.agent.roster.inlineSetup.title": "Agent-Einrichtung", diff --git a/web/i18n/en-US/permission-keys.json b/web/i18n/en-US/permission-keys.json index 62719a9c8ad..c42c65f6b5e 100644 --- a/web/i18n/en-US/permission-keys.json +++ b/web/i18n/en-US/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Manage Agents", "api_extension.manage": "Manage API extension configuration", "app.access_config": "Configure app access permissions", "app.acl.access_config": "Configure app access permissions", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 8f35a0d9a21..e7f5f9c5649 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "This integration is installed from GitHub. Please go to Integrations to reinstall", "nodes.agent.roster.change": "Change", "nodes.agent.roster.editInConsole": "Edit in Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "You do not have permission to manage Agents", "nodes.agent.roster.inlineSetup.description": "A one-off setup that lives only in this node", "nodes.agent.roster.inlineSetup.name": "Inline setup", "nodes.agent.roster.inlineSetup.title": "Agent setup", diff --git a/web/i18n/es-ES/permission-keys.json b/web/i18n/es-ES/permission-keys.json index 2e9bbca5dbc..f3e1f4abf78 100644 --- a/web/i18n/es-ES/permission-keys.json +++ b/web/i18n/es-ES/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Gestionar Agents", "api_extension.manage": "Gestionar la configuración de la extensión de API", "app.access_config": "Configurar los permisos de acceso de la app", "app.acl.access_config": "Configurar los permisos de acceso de la app", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index c9ab300565e..7aefb4af4dd 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Este integración se instala desde GitHub. Por favor, vaya a Integraciones para reinstalar", "nodes.agent.roster.change": "Cambiar", "nodes.agent.roster.editInConsole": "Editar en Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "No tienes permiso para gestionar Agents", "nodes.agent.roster.inlineSetup.description": "Una configuración puntual que existe solo en este nodo", "nodes.agent.roster.inlineSetup.name": "Configuración inline", "nodes.agent.roster.inlineSetup.title": "Configuración del agente", diff --git a/web/i18n/fa-IR/permission-keys.json b/web/i18n/fa-IR/permission-keys.json index cffb7058d0a..89792a325fb 100644 --- a/web/i18n/fa-IR/permission-keys.json +++ b/web/i18n/fa-IR/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "مدیریت Agents", "api_extension.manage": "مدیریت پیکربندی افزونه API", "app.access_config": "پیکربندی مجوزهای دسترسی برنامه", "app.acl.access_config": "پیکربندی مجوزهای دسترسی برنامه", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index e37e2919295..ab3c675e894 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "این یکپارچه‌سازی از GitHub نصب شده. برای نصب مجدد به بخش یکپارچه‌سازی‌ها بروید.", "nodes.agent.roster.change": "تغییر", "nodes.agent.roster.editInConsole": "ویرایش در Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "شما مجوز مدیریت Agents را ندارید", "nodes.agent.roster.inlineSetup.description": "یک پیکربندی یک‌باره که فقط در این گره وجود دارد", "nodes.agent.roster.inlineSetup.name": "پیکربندی inline", "nodes.agent.roster.inlineSetup.title": "پیکربندی عامل", diff --git a/web/i18n/fr-FR/permission-keys.json b/web/i18n/fr-FR/permission-keys.json index 02ebee3549f..bd6419a0834 100644 --- a/web/i18n/fr-FR/permission-keys.json +++ b/web/i18n/fr-FR/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Gérer Agents", "api_extension.manage": "Gérer la configuration de l'extension API", "app.access_config": "Configurer les autorisations d'accès à l'application", "app.acl.access_config": "Configurer les autorisations d'accès à l'application", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index 52b1fa0ea3e..e4062e93851 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Ce intégration est installé à partir de GitHub. Veuillez aller dans Intégrations pour réinstaller", "nodes.agent.roster.change": "Changer", "nodes.agent.roster.editInConsole": "Modifier dans Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Vous n'avez pas la permission de gérer les Agents", "nodes.agent.roster.inlineSetup.description": "Une configuration ponctuelle qui n’existe que dans ce nœud", "nodes.agent.roster.inlineSetup.name": "Configuration inline", "nodes.agent.roster.inlineSetup.title": "Configuration de l’agent", diff --git a/web/i18n/hi-IN/permission-keys.json b/web/i18n/hi-IN/permission-keys.json index 8b29caeaac9..19e879d4491 100644 --- a/web/i18n/hi-IN/permission-keys.json +++ b/web/i18n/hi-IN/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agents प्रबंधित करें", "api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें", "app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", "app.acl.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index e3cf845ad39..bcd97d4834a 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "यह एकीकरण गिटहब से स्थापित किया गया है। कृपया पुनः स्थापित करने के लिए एकीकरण पर जाएं।", "nodes.agent.roster.change": "बदलें", "nodes.agent.roster.editInConsole": "Agent Console में संपादित करें", + "nodes.agent.roster.editInConsoleDisabled": "आपके पास Agents प्रबंधित करने की अनुमति नहीं है", "nodes.agent.roster.inlineSetup.description": "एक एकल सेटअप जो केवल इस नोड में रहता है", "nodes.agent.roster.inlineSetup.name": "इनलाइन सेटअप", "nodes.agent.roster.inlineSetup.title": "एजेंट सेटअप", diff --git a/web/i18n/id-ID/permission-keys.json b/web/i18n/id-ID/permission-keys.json index 656acd454c5..450fe588995 100644 --- a/web/i18n/id-ID/permission-keys.json +++ b/web/i18n/id-ID/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Kelola Agents", "api_extension.manage": "Kelola konfigurasi ekstensi API", "app.access_config": "Konfigurasikan izin akses aplikasi", "app.acl.access_config": "Konfigurasikan izin akses aplikasi", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 5fe6e34f2b3..5414150d9d1 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Integrasi ini diinstal dari GitHub. Silakan buka Integrasis untuk menginstal ulang", "nodes.agent.roster.change": "Ubah", "nodes.agent.roster.editInConsole": "Edit di Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Anda tidak memiliki izin untuk mengelola Agents", "nodes.agent.roster.inlineSetup.description": "Pengaturan sekali pakai yang hanya berada di simpul ini", "nodes.agent.roster.inlineSetup.name": "Pengaturan inline", "nodes.agent.roster.inlineSetup.title": "Pengaturan agen", diff --git a/web/i18n/it-IT/permission-keys.json b/web/i18n/it-IT/permission-keys.json index 1150f0bc405..10a9ff715f8 100644 --- a/web/i18n/it-IT/permission-keys.json +++ b/web/i18n/it-IT/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Gestisci Agents", "api_extension.manage": "Gestisci la configurazione delle estensioni API", "app.access_config": "Configura i permessi di accesso all'app", "app.acl.access_config": "Configura i permessi di accesso all'app", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 2679d236b04..55984d91547 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Questo integrazione viene installato da GitHub. Vai su Integrazione per reinstallare", "nodes.agent.roster.change": "Cambia", "nodes.agent.roster.editInConsole": "Modifica in Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Non hai i permessi per gestire gli Agents", "nodes.agent.roster.inlineSetup.description": "Una configurazione una tantum che vive solo in questo nodo", "nodes.agent.roster.inlineSetup.name": "Configurazione inline", "nodes.agent.roster.inlineSetup.title": "Configurazione dell’agente", diff --git a/web/i18n/ja-JP/permission-keys.json b/web/i18n/ja-JP/permission-keys.json index 9834932af62..e1e8119ceef 100644 --- a/web/i18n/ja-JP/permission-keys.json +++ b/web/i18n/ja-JP/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agentsを管理", "api_extension.manage": "API拡張設定を管理", "app.access_config": "アプリアクセス権限を設定", "app.acl.access_config": "アプリアクセス権限を設定", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 6204ccf87d6..96f00b13d4e 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "このインテグレーションは GitHub からインストールされています。再インストールするにはインテグレーションに移動してください。", "nodes.agent.roster.change": "変更", "nodes.agent.roster.editInConsole": "Agent Console で編集", + "nodes.agent.roster.editInConsoleDisabled": "Agents を管理する権限がありません", "nodes.agent.roster.inlineSetup.description": "このノード内にのみ存在する一度きりのセットアップ", "nodes.agent.roster.inlineSetup.name": "インラインセットアップ", "nodes.agent.roster.inlineSetup.title": "Agent セットアップ", diff --git a/web/i18n/ko-KR/permission-keys.json b/web/i18n/ko-KR/permission-keys.json index 53c3a181185..07a44cc3675 100644 --- a/web/i18n/ko-KR/permission-keys.json +++ b/web/i18n/ko-KR/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agents 관리", "api_extension.manage": "API 확장 구성 관리", "app.access_config": "앱 접근 권한 구성", "app.acl.access_config": "앱 접근 권한 구성", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index b9f0eed2871..62550cdbed7 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "이 통합은 GitHub 에서 설치됩니다. 통합으로 이동하여 다시 설치하십시오.", "nodes.agent.roster.change": "변경", "nodes.agent.roster.editInConsole": "Agent Console에서 편집", + "nodes.agent.roster.editInConsoleDisabled": "Agents를 관리할 권한이 없습니다", "nodes.agent.roster.inlineSetup.description": "이 노드 내에만 존재하는 일회성 설정", "nodes.agent.roster.inlineSetup.name": "인라인 설정", "nodes.agent.roster.inlineSetup.title": "Agent 설정", diff --git a/web/i18n/nl-NL/permission-keys.json b/web/i18n/nl-NL/permission-keys.json index 3c94a6ea6f8..1b2b567a3f6 100644 --- a/web/i18n/nl-NL/permission-keys.json +++ b/web/i18n/nl-NL/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agents beheren", "api_extension.manage": "API-extensieconfiguratie beheren", "app.access_config": "Toegangsrechten voor app configureren", "app.acl.access_config": "Toegangsrechten voor app configureren", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index b43b470ea3d..b3503ec392a 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "This integratie is installed from GitHub. Please go to Integraties to reinstall", "nodes.agent.roster.change": "Wijzigen", "nodes.agent.roster.editInConsole": "Bewerken in Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Je hebt geen toestemming om Agents te beheren", "nodes.agent.roster.inlineSetup.description": "Een eenmalige setup die alleen in dit knooppunt bestaat", "nodes.agent.roster.inlineSetup.name": "Inline-setup", "nodes.agent.roster.inlineSetup.title": "Agent-setup", diff --git a/web/i18n/pl-PL/permission-keys.json b/web/i18n/pl-PL/permission-keys.json index 39604d0a19a..bf5883470dc 100644 --- a/web/i18n/pl-PL/permission-keys.json +++ b/web/i18n/pl-PL/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Zarządzaj Agents", "api_extension.manage": "Zarządzaj konfiguracją rozszerzenia API", "app.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", "app.acl.access_config": "Konfiguruj uprawnienia dostępu do aplikacji", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 3a052ae1efe..bcc5e456f29 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Ta integracja jest instalowana z GitHub. Przejdź do Integracje, aby ponownie zainstalować", "nodes.agent.roster.change": "Zmień", "nodes.agent.roster.editInConsole": "Edytuj w Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Nie masz uprawnień do zarządzania Agents", "nodes.agent.roster.inlineSetup.description": "Jednorazowa konfiguracja istniejąca tylko w tym węźle", "nodes.agent.roster.inlineSetup.name": "Konfiguracja inline", "nodes.agent.roster.inlineSetup.title": "Konfiguracja agenta", diff --git a/web/i18n/pt-BR/permission-keys.json b/web/i18n/pt-BR/permission-keys.json index 13aa99d883c..9e8580c6284 100644 --- a/web/i18n/pt-BR/permission-keys.json +++ b/web/i18n/pt-BR/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Gerenciar Agents", "api_extension.manage": "Gerenciar configuração de extensão de API", "app.access_config": "Configurar permissões de acesso ao aplicativo", "app.acl.access_config": "Configurar permissões de acesso ao aplicativo", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index a56d58e4509..b0e27beb31f 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Este integração é instalado a partir do GitHub. Por favor, vá para Integrações para reinstalar", "nodes.agent.roster.change": "Alterar", "nodes.agent.roster.editInConsole": "Editar no Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Você não tem permissão para gerenciar Agents", "nodes.agent.roster.inlineSetup.description": "Uma configuração pontual que existe apenas neste nó", "nodes.agent.roster.inlineSetup.name": "Configuração inline", "nodes.agent.roster.inlineSetup.title": "Configuração do agente", diff --git a/web/i18n/ro-RO/permission-keys.json b/web/i18n/ro-RO/permission-keys.json index 37809a7336a..5a2af95c87f 100644 --- a/web/i18n/ro-RO/permission-keys.json +++ b/web/i18n/ro-RO/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Gestionează Agents", "api_extension.manage": "Gestionează configurația extensiei API", "app.access_config": "Configurează permisiunile de acces ale aplicației", "app.acl.access_config": "Configurează permisiunile de acces ale aplicației", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index d1acc740067..5e4f689fd15 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Acest integrare este instalat de pe GitHub. Vă rugăm să accesați Integrări pentru a reinstala", "nodes.agent.roster.change": "Schimbă", "nodes.agent.roster.editInConsole": "Editează în Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Nu aveți permisiunea de a gestiona Agents", "nodes.agent.roster.inlineSetup.description": "O configurare unică ce există doar în acest nod", "nodes.agent.roster.inlineSetup.name": "Configurare inline", "nodes.agent.roster.inlineSetup.title": "Configurarea agentului", diff --git a/web/i18n/ru-RU/permission-keys.json b/web/i18n/ru-RU/permission-keys.json index 27d0985d274..58d20cee32c 100644 --- a/web/i18n/ru-RU/permission-keys.json +++ b/web/i18n/ru-RU/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Управление Agents", "api_extension.manage": "Управление конфигурацией API-расширений", "app.access_config": "Настройка прав доступа к приложению", "app.acl.access_config": "Настройка прав доступа к приложению", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index bb656c122bf..79b5226000f 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Этот интеграция устанавливается с GitHub. Пожалуйста, перейдите в раздел Интеграции для переустановки", "nodes.agent.roster.change": "Сменить", "nodes.agent.roster.editInConsole": "Редактировать в Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "У вас нет прав на управление Agents", "nodes.agent.roster.inlineSetup.description": "Одноразовая настройка, существующая только в этом узле", "nodes.agent.roster.inlineSetup.name": "Встроенная настройка", "nodes.agent.roster.inlineSetup.title": "Настройка агента", diff --git a/web/i18n/sl-SI/permission-keys.json b/web/i18n/sl-SI/permission-keys.json index 58e5966e05b..10a915a281f 100644 --- a/web/i18n/sl-SI/permission-keys.json +++ b/web/i18n/sl-SI/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Upravljanje Agents", "api_extension.manage": "Upravljanje konfiguracije razširitve API", "app.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", "app.acl.access_config": "Konfiguracija dovoljenj za dostop do aplikacije", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 1a2fc56648d..0141163c9da 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Ta integracija je nameščen iz GitHuba. Prosimo, da greste v integracijae in ga ponovo namestite.", "nodes.agent.roster.change": "Spremeni", "nodes.agent.roster.editInConsole": "Uredi v Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Nimate dovoljenja za upravljanje Agents", "nodes.agent.roster.inlineSetup.description": "Enkratna namestitev, ki obstaja le v tem vozlišču", "nodes.agent.roster.inlineSetup.name": "Vgrajena nastavitev", "nodes.agent.roster.inlineSetup.title": "Nastavitev agenta", diff --git a/web/i18n/th-TH/permission-keys.json b/web/i18n/th-TH/permission-keys.json index 40509c24552..a63c22b1f4c 100644 --- a/web/i18n/th-TH/permission-keys.json +++ b/web/i18n/th-TH/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "จัดการ Agents", "api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API", "app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", "app.acl.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index f964975948b..6f26185d00a 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "การผสานรวมนี้ติดตั้งจาก GitHub โปรดไปที่การผสานรวมเพื่อติดตั้งใหม่", "nodes.agent.roster.change": "เปลี่ยน", "nodes.agent.roster.editInConsole": "แก้ไขใน Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "คุณไม่มีสิทธิ์จัดการ Agents", "nodes.agent.roster.inlineSetup.description": "การตั้งค่าครั้งเดียวที่อยู่เฉพาะในโหนดนี้", "nodes.agent.roster.inlineSetup.name": "การตั้งค่าแบบอินไลน์", "nodes.agent.roster.inlineSetup.title": "การตั้งค่า Agent", diff --git a/web/i18n/tr-TR/permission-keys.json b/web/i18n/tr-TR/permission-keys.json index fa2ddc67e73..c710082bb51 100644 --- a/web/i18n/tr-TR/permission-keys.json +++ b/web/i18n/tr-TR/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Agents'ı yönet", "api_extension.manage": "API uzantısı yapılandırmasını yönet", "app.access_config": "Uygulama erişim izinlerini yapılandır", "app.acl.access_config": "Uygulama erişim izinlerini yapılandır", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index e2170efb95f..e02c6068804 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Bu entegrasyon GitHub'dan yüklenmiştir. Lütfen şuraya gidin: Entegrasyonlar yeniden yüklemek için", "nodes.agent.roster.change": "Değiştir", "nodes.agent.roster.editInConsole": "Agent Console'da düzenle", + "nodes.agent.roster.editInConsoleDisabled": "Agents'ları yönetme izniniz yok", "nodes.agent.roster.inlineSetup.description": "Yalnızca bu düğümde yaşayan tek seferlik bir kurulum", "nodes.agent.roster.inlineSetup.name": "Inline kurulum", "nodes.agent.roster.inlineSetup.title": "Ajan kurulumu", diff --git a/web/i18n/uk-UA/permission-keys.json b/web/i18n/uk-UA/permission-keys.json index 46500c63dac..1b2c971758c 100644 --- a/web/i18n/uk-UA/permission-keys.json +++ b/web/i18n/uk-UA/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Керування Agents", "api_extension.manage": "Керування конфігурацією розширення API", "app.access_config": "Налаштування дозволів доступу до застосунку", "app.acl.access_config": "Налаштування дозволів доступу до застосунку", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 85f314b0396..720d81292d5 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Цей інтеграція встановлюється з GitHub. Будь ласка, перейдіть до Інтеграції для перевстановлення", "nodes.agent.roster.change": "Змінити", "nodes.agent.roster.editInConsole": "Редагувати в Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "У вас немає прав на керування Agents", "nodes.agent.roster.inlineSetup.description": "Одноразове налаштування, що існує лише в цьому вузлі", "nodes.agent.roster.inlineSetup.name": "Вбудоване налаштування", "nodes.agent.roster.inlineSetup.title": "Налаштування агента", diff --git a/web/i18n/vi-VN/permission-keys.json b/web/i18n/vi-VN/permission-keys.json index 62029e14a6f..072bd090294 100644 --- a/web/i18n/vi-VN/permission-keys.json +++ b/web/i18n/vi-VN/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "Quản lý Agents", "api_extension.manage": "Quản lý cấu hình phần mở rộng API", "app.access_config": "Cấu hình quyền truy cập ứng dụng", "app.acl.access_config": "Cấu hình quyền truy cập ứng dụng", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 314ecc3ea16..b6ab2206358 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "Tích hợp này được cài đặt từ GitHub. Vui lòng vào Tích hợp để cài đặt lại", "nodes.agent.roster.change": "Thay đổi", "nodes.agent.roster.editInConsole": "Chỉnh sửa trong Agent Console", + "nodes.agent.roster.editInConsoleDisabled": "Bạn không có quyền quản lý Agents", "nodes.agent.roster.inlineSetup.description": "Một thiết lập một lần chỉ tồn tại trong nút này", "nodes.agent.roster.inlineSetup.name": "Thiết lập inline", "nodes.agent.roster.inlineSetup.title": "Thiết lập tác nhân", diff --git a/web/i18n/zh-Hans/permission-keys.json b/web/i18n/zh-Hans/permission-keys.json index d560ca88392..a8b462e8f44 100644 --- a/web/i18n/zh-Hans/permission-keys.json +++ b/web/i18n/zh-Hans/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "管理 Agents", "api_extension.manage": "管理API扩展", "app.access_config": "配置应用访问权限", "app.acl.access_config": "配置应用访问权限", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 500b044ad17..9833b253d47 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "此集成安装自 GitHub。请前往集成重新安装", "nodes.agent.roster.change": "更换", "nodes.agent.roster.editInConsole": "在 Agent Console 中编辑", + "nodes.agent.roster.editInConsoleDisabled": "你没有管理 Agents 的权限", "nodes.agent.roster.inlineSetup.description": "仅存在于此节点的一次性设置", "nodes.agent.roster.inlineSetup.name": "内联配置", "nodes.agent.roster.inlineSetup.title": "Agent 设置", diff --git a/web/i18n/zh-Hant/permission-keys.json b/web/i18n/zh-Hant/permission-keys.json index 34a0109b400..4542340a1d5 100644 --- a/web/i18n/zh-Hant/permission-keys.json +++ b/web/i18n/zh-Hant/permission-keys.json @@ -1,4 +1,5 @@ { + "agent.manage": "管理 Agents", "api_extension.manage": "管理API擴充配置", "app.access_config": "配置應用訪問權限", "app.acl.access_config": "配置應用訪問權限", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index e32e5058b6f..b2a3f1c05ce 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -445,6 +445,7 @@ "nodes.agent.pluginNotInstalledDesc": "此集成安裝自 GitHub。請前往集成重新安裝", "nodes.agent.roster.change": "更換", "nodes.agent.roster.editInConsole": "在 Agent Console 中編輯", + "nodes.agent.roster.editInConsoleDisabled": "你沒有管理 Agents 的權限", "nodes.agent.roster.inlineSetup.description": "僅存在於此節點的一次性設定", "nodes.agent.roster.inlineSetup.name": "內聯設定", "nodes.agent.roster.inlineSetup.title": "Agent 設定",