Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f729bc87a2 | ||
|
|
160a1bc405 | ||
|
|
6b8937f3e1 | ||
|
|
d673cc96ae | ||
|
|
553c38b1f2 | ||
|
|
9e74520b50 | ||
|
|
c56b46a318 | ||
|
|
d16d2d1efa | ||
|
|
91126a5fda | ||
|
|
8482167f1c | ||
|
|
6cd10f5e04 | ||
|
|
1b5fbb2142 | ||
|
|
60885db938 | ||
|
|
1ba65a3431 | ||
|
|
09d7e44bd1 | ||
|
|
67451b0ecc | ||
|
|
cd62373421 | ||
|
|
245c6c3525 | ||
|
|
4e95e73d86 | ||
|
|
fd88da9743 | ||
|
|
c5b345636d | ||
|
|
2e30c59652 | ||
|
|
6cde1271aa | ||
|
|
1ff2d022ac | ||
|
|
0064a2d8ff | ||
|
|
8dd62b6cef | ||
|
|
09338631bc | ||
|
|
1c58bd0f15 | ||
|
|
8ce3063ece | ||
|
|
28a7c247b9 | ||
|
|
bddf574994 | ||
|
|
bfd4de159e | ||
|
|
2279298c1a | ||
|
|
6056646eea | ||
|
|
e2d0528e29 | ||
|
|
ac87341d69 | ||
|
|
0a2ae4ba1d | ||
|
|
058fa95517 | ||
|
|
75cb8ea408 | ||
|
|
457e1e9187 | ||
|
|
edbaaeae23 | ||
|
|
79370e8c75 | ||
|
|
c039a15014 | ||
|
|
66473d5d51 | ||
|
|
024ba2571e | ||
|
|
4626a9bc59 | ||
|
|
51e985665f | ||
|
|
7037f2acc5 | ||
|
|
13063dbc10 | ||
|
|
5e48e39d10 | ||
|
|
e606156580 | ||
|
|
c56ff84565 | ||
|
|
525e76b577 | ||
|
|
181f58ed9c | ||
|
|
f947cbfffb | ||
|
|
87e7b5660a | ||
|
|
8440564007 | ||
|
|
582ddbce43 | ||
|
|
222169e8d5 | ||
|
|
ac63b8c671 | ||
|
|
ffe318906b | ||
|
|
787cda9ee1 | ||
|
|
6b8b27962d | ||
|
|
1e5a07ba21 | ||
|
|
509f92ef2d | ||
|
|
ebe83f3976 | ||
|
|
f2ba0a7c08 | ||
|
|
816c6cad82 | ||
|
|
4ae9028d4a | ||
|
|
2ee957bf49 | ||
|
|
1f9ffc1680 | ||
|
|
96f63dfb1e | ||
|
|
37b720fd1d | ||
|
|
2ef1b05afc | ||
|
|
1215fb7c25 | ||
|
|
3c460a3bec | ||
|
|
a14a746931 | ||
|
|
03a005ce2c | ||
|
|
dac931b5f1 | ||
|
|
e284ee15a1 | ||
|
|
9a3968aba0 | ||
|
|
3e8b14f8e0 | ||
|
|
5c42d0928b | ||
|
|
d34f3af9c3 | ||
|
|
ae41e8d267 | ||
|
|
48c347eeb7 | ||
|
|
afc9109148 |
@@ -1,8 +1,17 @@
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
from urllib.parse import parse_qsl, quote_plus
|
||||
from urllib.parse import urlparse as _urlparse
|
||||
|
||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||
from pydantic import (
|
||||
Field,
|
||||
NonNegativeFloat,
|
||||
NonNegativeInt,
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
from .cache.redis_config import RedisConfig
|
||||
@@ -408,4 +417,22 @@ class MiddlewareConfig(
|
||||
DatasetQueueMonitorConfig,
|
||||
MatrixoneConfig,
|
||||
):
|
||||
pass
|
||||
@model_validator(mode="after")
|
||||
def _validate_redis_urls_db_for_azure(self):
|
||||
"""Azure Managed Redis only supports db 0; reject non-zero db in Redis URLs."""
|
||||
if not self.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
return self
|
||||
|
||||
for url, name in (
|
||||
(self.CELERY_BROKER_URL, "CELERY_BROKER_URL"),
|
||||
(self.PUBSUB_REDIS_URL, "PUBSUB_REDIS_URL"),
|
||||
):
|
||||
if not url:
|
||||
continue
|
||||
db: str = _urlparse(url).path.lstrip("/") or "0"
|
||||
if db != "0":
|
||||
raise ValueError(
|
||||
f"Azure Managed Redis only supports db 0, but {name} uses db {db}. "
|
||||
"Please set the db index to 0 in your URL."
|
||||
)
|
||||
return self
|
||||
|
||||
+32
-4
@@ -1,4 +1,4 @@
|
||||
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator
|
||||
from pydantic import Field, NonNegativeInt, PositiveFloat, PositiveInt, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ class RedisConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY: bool = Field(
|
||||
description="Use Azure Managed Identity (Entra ID) for Redis authentication."
|
||||
" When enabled, username/password are ignored and a token is acquired via DefaultAzureCredential."
|
||||
" Requires azure-identity and redis-entraid packages.",
|
||||
default=False,
|
||||
)
|
||||
|
||||
REDIS_SSL_CERT_REQS: str = Field(
|
||||
description="SSL certificate requirements (CERT_NONE, CERT_OPTIONAL, CERT_REQUIRED)",
|
||||
default="CERT_NONE",
|
||||
@@ -158,12 +165,33 @@ class RedisConfig(BaseSettings):
|
||||
REDIS_KEEPALIVE_INTERVAL: PositiveInt = Field(default=10, description="redis keepalive interval")
|
||||
REDIS_KEEPALIVE_COUNT: PositiveInt = Field(default=10, description="redis keepalive count")
|
||||
|
||||
@field_validator("REDIS_MAX_CONNECTIONS", mode="before")
|
||||
@field_validator(
|
||||
"REDIS_SSL_CA_CERTS",
|
||||
"REDIS_SSL_CERTFILE",
|
||||
"REDIS_SSL_KEYFILE",
|
||||
"REDIS_MAX_CONNECTIONS",
|
||||
mode="before",
|
||||
)
|
||||
@classmethod
|
||||
def _empty_string_to_none_for_max_conns(cls, v):
|
||||
"""Allow empty string in env/.env to mean 'unset' (None)."""
|
||||
def _empty_string_to_none(cls, v):
|
||||
"""Allow empty string in env/.env to mean 'unset' (None).
|
||||
|
||||
Particularly important for SSL file paths: an empty string would cause
|
||||
redis-py to call ``ssl.SSLContext.load_verify_locations(cafile="")``
|
||||
which raises ``FileNotFoundError``.
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str) and v.strip() == "":
|
||||
return None
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_azure_managed_identity(self):
|
||||
"""Azure Managed Redis only supports db 0."""
|
||||
if self.REDIS_USE_AZURE_MANAGED_IDENTITY and self.REDIS_DB != 0:
|
||||
raise ValueError(
|
||||
f"Azure Managed Redis only supports db 0, but REDIS_DB is set to {self.REDIS_DB}. "
|
||||
"Please set REDIS_DB=0 when REDIS_USE_AZURE_MANAGED_IDENTITY is enabled."
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -23,6 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -35,6 +36,7 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Azure-specific helpers for Redis authentication via Entra ID (Managed Identity)."""
|
||||
|
||||
from typing import Union, override
|
||||
|
||||
from redis import CredentialProvider
|
||||
|
||||
AZURE_REDIS_SCOPE = "https://redis.azure.com/.default"
|
||||
|
||||
|
||||
class AzureEntraIdCredentialProvider(CredentialProvider):
|
||||
"""Redis credential provider for Azure Entra ID (Managed Identity) authentication.
|
||||
|
||||
Wraps ``redis-entraid``'s provider so that it can be instantiated with no
|
||||
arguments — required by kombu's URL-based ``credential_provider`` resolution.
|
||||
"""
|
||||
|
||||
_inner: CredentialProvider
|
||||
|
||||
def __init__(self) -> None:
|
||||
from redis_entraid.cred_provider import create_from_default_azure_credential
|
||||
|
||||
self._inner = create_from_default_azure_credential(
|
||||
scopes=(AZURE_REDIS_SCOPE,),
|
||||
)
|
||||
|
||||
@override
|
||||
def get_credentials(self) -> Union[tuple[str], tuple[str, str]]:
|
||||
return self._inner.get_credentials()
|
||||
|
||||
|
||||
def get_azure_credential_provider() -> CredentialProvider:
|
||||
"""Create a redis-py credential provider for Azure Entra ID authentication."""
|
||||
from redis_entraid.cred_provider import create_from_default_azure_credential
|
||||
|
||||
return create_from_default_azure_credential(
|
||||
scopes=(AZURE_REDIS_SCOPE,),
|
||||
)
|
||||
|
||||
|
||||
def apply_azure_redis_auth(params: dict) -> None:
|
||||
"""Apply Azure Entra ID authentication to a Redis connection params dict.
|
||||
|
||||
Removes static username/password and injects a credential_provider instead.
|
||||
"""
|
||||
params.pop("username", None)
|
||||
params.pop("password", None)
|
||||
params["credential_provider"] = get_azure_credential_provider()
|
||||
|
||||
|
||||
def apply_azure_celery_broker_auth(celery_app, broker_url: str) -> None:
|
||||
"""Configure Celery broker to authenticate via Azure Entra ID credential provider."""
|
||||
cred_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
|
||||
sep = "&" if "?" in broker_url else "?"
|
||||
broker_url_with_cred = f"{broker_url}{sep}{cred_param}"
|
||||
celery_app.conf.update(
|
||||
broker_read_url=broker_url_with_cred,
|
||||
broker_write_url=broker_url_with_cred,
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
@@ -9,9 +10,12 @@ from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
from dify_app import DifyApp
|
||||
from extensions.azure import AzureEntraIdCredentialProvider, apply_azure_celery_broker_auth
|
||||
from extensions.redis_names import normalize_redis_key_prefix
|
||||
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CelerySentinelKwargsDict(TypedDict):
|
||||
socket_timeout: float | None
|
||||
@@ -108,10 +112,12 @@ def init_app(app: DifyApp) -> Celery:
|
||||
|
||||
broker_transport_options = get_celery_broker_transport_options()
|
||||
|
||||
broker_url = dify_config.CELERY_BROKER_URL
|
||||
|
||||
celery_app = Celery(
|
||||
app.name,
|
||||
task_cls=FlaskTask,
|
||||
broker=dify_config.CELERY_BROKER_URL,
|
||||
broker=broker_url,
|
||||
backend=dify_config.CELERY_BACKEND,
|
||||
)
|
||||
|
||||
@@ -128,9 +134,12 @@ def init_app(app: DifyApp) -> Celery:
|
||||
)
|
||||
|
||||
if dify_config.CELERY_BACKEND == "redis":
|
||||
celery_app.conf.update(
|
||||
result_backend_transport_options=broker_transport_options,
|
||||
)
|
||||
redis_backend_conf: dict[str, Any] = {
|
||||
"result_backend_transport_options": broker_transport_options,
|
||||
}
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
redis_backend_conf["redis_backend_credential_provider"] = AzureEntraIdCredentialProvider()
|
||||
celery_app.conf.update(**redis_backend_conf)
|
||||
|
||||
# Apply SSL configuration if enabled
|
||||
ssl_options = get_celery_ssl_options()
|
||||
@@ -141,6 +150,10 @@ def init_app(app: DifyApp) -> Celery:
|
||||
redis_backend_use_ssl=ssl_options if dify_config.CELERY_BACKEND == "redis" else None,
|
||||
)
|
||||
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY and broker_url:
|
||||
apply_azure_celery_broker_auth(celery_app, broker_url)
|
||||
logger.info("Celery broker: using Azure Managed Identity (Entra ID) authentication")
|
||||
|
||||
if dify_config.LOG_FILE:
|
||||
celery_app.conf.update(
|
||||
worker_logfile=dify_config.LOG_FILE,
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
from dify_app import DifyApp
|
||||
from extensions.azure import apply_azure_redis_auth, get_azure_credential_provider
|
||||
from extensions.redis_names import (
|
||||
normalize_redis_key_prefix,
|
||||
serialize_redis_name,
|
||||
@@ -438,6 +439,10 @@ def _create_standalone_client(redis_params: RedisBaseParamsDict) -> Union[redis.
|
||||
"connection_class": connection_class,
|
||||
}
|
||||
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
apply_azure_redis_auth(params)
|
||||
logger.info("Redis: using Azure Managed Identity (Entra ID) authentication")
|
||||
|
||||
if dify_config.REDIS_MAX_CONNECTIONS:
|
||||
params["max_connections"] = dify_config.REDIS_MAX_CONNECTIONS
|
||||
|
||||
@@ -457,12 +462,20 @@ def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> redis.Redis |
|
||||
kwargs: dict[str, Any] = {**health_params}
|
||||
if max_conns:
|
||||
kwargs["max_connections"] = max_conns
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
kwargs["credential_provider"] = get_azure_credential_provider()
|
||||
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
|
||||
logger.info("PubSub Redis (cluster): using Azure Managed Identity (Entra ID) authentication")
|
||||
return RedisCluster.from_url(pubsub_url, **kwargs)
|
||||
|
||||
standalone_health_params: dict[str, Any] = dict(_get_connection_health_params())
|
||||
kwargs = {**standalone_health_params}
|
||||
if max_conns:
|
||||
kwargs["max_connections"] = max_conns
|
||||
if dify_config.REDIS_USE_AZURE_MANAGED_IDENTITY:
|
||||
kwargs["credential_provider"] = get_azure_credential_provider()
|
||||
kwargs["ssl_cert_reqs"] = ssl.CERT_NONE
|
||||
logger.info("PubSub Redis: using Azure Managed Identity (Entra ID) authentication")
|
||||
return redis.Redis.from_url(pubsub_url, **kwargs)
|
||||
|
||||
|
||||
@@ -486,7 +499,7 @@ def init_app(app: DifyApp):
|
||||
|
||||
global _pubsub_redis_client
|
||||
_pubsub_redis_client = client
|
||||
if dify_config.normalized_pubsub_redis_url:
|
||||
if dify_config.PUBSUB_REDIS_URL:
|
||||
_pubsub_redis_client = _create_pubsub_client(
|
||||
dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
|
||||
)
|
||||
|
||||
+32
-14
@@ -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:
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"psycopg2-binary>=2.9.12,<3.0.0",
|
||||
"python-socketio>=5.13.0,<6.0.0",
|
||||
"redis[hiredis]>=7.4.0,<8.0.0",
|
||||
"redis-entraid>=1.2.0,<2.0.0",
|
||||
"sendgrid>=6.12.5,<7.0.0",
|
||||
"sseclient-py>=1.8.0,<2.0.0",
|
||||
# Stable: production-proven, cap below the next major
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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] = [
|
||||
|
||||
@@ -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"}]
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import inspect
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.workspace.plugin_model_providers import (
|
||||
EnterprisePluginModelProvidersCacheInvalidate,
|
||||
InvalidatePluginModelProvidersCachePayload,
|
||||
)
|
||||
|
||||
|
||||
class TestInvalidatePluginModelProvidersCachePayload:
|
||||
def test_valid_payload(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
|
||||
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
|
||||
)
|
||||
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
|
||||
|
||||
def test_missing_tenant_ids_defaults_to_empty(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
|
||||
assert payload.tenant_ids == []
|
||||
|
||||
def test_unknown_field_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
|
||||
|
||||
|
||||
class TestEnterprisePluginModelProvidersCacheInvalidate:
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterprisePluginModelProvidersCacheInvalidate()
|
||||
|
||||
def _post(self, api_instance, app: Flask, payload):
|
||||
unwrapped_post = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = payload
|
||||
return unwrapped_post(api_instance)
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
|
||||
call("tenant-alpha"),
|
||||
call("tenant-beta"),
|
||||
]
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": []})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, None)
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Tests for Azure Managed Identity Redis helpers."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from configs import DifyConfig
|
||||
from configs.middleware.cache.redis_config import RedisConfig
|
||||
from extensions.azure import (
|
||||
AzureEntraIdCredentialProvider,
|
||||
apply_azure_celery_broker_auth,
|
||||
apply_azure_redis_auth,
|
||||
get_azure_credential_provider,
|
||||
)
|
||||
|
||||
|
||||
class TestAzureConfigValidation:
|
||||
"""Test pydantic config validation for Azure Managed Redis constraints."""
|
||||
|
||||
def test_redis_db_0_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=0)
|
||||
assert config.REDIS_DB == 0
|
||||
|
||||
def test_redis_db_nonzero_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True, REDIS_DB=1)
|
||||
|
||||
def test_redis_db_nonzero_without_azure_mi_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=False, REDIS_DB=5)
|
||||
assert config.REDIS_DB == 5
|
||||
|
||||
def test_redis_db_default_with_azure_mi_passes(self):
|
||||
config = RedisConfig(REDIS_USE_AZURE_MANAGED_IDENTITY=True)
|
||||
assert config.REDIS_DB == 0
|
||||
|
||||
def test_celery_broker_url_db_0_with_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
CELERY_BROKER_URL="rediss://:@host:10000/0",
|
||||
)
|
||||
assert config.CELERY_BROKER_URL == "rediss://:@host:10000/0"
|
||||
|
||||
def test_celery_broker_url_nonzero_db_with_azure_mi_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
CELERY_BROKER_URL="rediss://:@host:10000/1",
|
||||
)
|
||||
|
||||
def test_celery_broker_url_nonzero_db_without_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
|
||||
CELERY_BROKER_URL="redis://localhost:6379/5",
|
||||
)
|
||||
assert config.CELERY_BROKER_URL == "redis://localhost:6379/5"
|
||||
|
||||
def test_pubsub_redis_url_db_0_with_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
PUBSUB_REDIS_URL="rediss://:@host:10000/0",
|
||||
)
|
||||
assert config.PUBSUB_REDIS_URL == "rediss://:@host:10000/0"
|
||||
|
||||
def test_pubsub_redis_url_nonzero_db_with_azure_mi_raises(self):
|
||||
with pytest.raises(ValueError, match="only supports db 0"):
|
||||
DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=True,
|
||||
REDIS_DB=0,
|
||||
PUBSUB_REDIS_URL="rediss://:@host:10000/2",
|
||||
)
|
||||
|
||||
def test_pubsub_redis_url_nonzero_db_without_azure_mi_passes(self):
|
||||
config = DifyConfig(
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=False,
|
||||
PUBSUB_REDIS_URL="redis://localhost:6379/3",
|
||||
)
|
||||
assert config.PUBSUB_REDIS_URL == "redis://localhost:6379/3"
|
||||
|
||||
|
||||
class TestApplyAzureCeleryBrokerAuth:
|
||||
"""Test apply_azure_celery_broker_auth Celery configuration."""
|
||||
|
||||
def test_sets_broker_read_and_write_urls(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
mock_app.conf.update.assert_called_once()
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
|
||||
assert "broker_read_url" in call_kwargs
|
||||
assert "broker_write_url" in call_kwargs
|
||||
|
||||
def test_appends_credential_provider_query_param(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
expected_param = "credential_provider=extensions.azure.AzureEntraIdCredentialProvider"
|
||||
assert expected_param in call_kwargs["broker_read_url"]
|
||||
assert expected_param in call_kwargs["broker_write_url"]
|
||||
|
||||
def test_uses_ampersand_when_url_already_has_query(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0?timeout=5")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
assert "&credential_provider=" in call_kwargs["broker_read_url"]
|
||||
|
||||
def test_uses_question_mark_when_url_has_no_query(self):
|
||||
mock_app = MagicMock()
|
||||
apply_azure_celery_broker_auth(mock_app, "rediss://:@host:10000/0")
|
||||
|
||||
call_kwargs = mock_app.conf.update.call_args[1]
|
||||
assert "?credential_provider=" in call_kwargs["broker_read_url"]
|
||||
|
||||
|
||||
class TestAzureEntraIdCredentialProvider:
|
||||
"""Test AzureEntraIdCredentialProvider wrapper."""
|
||||
|
||||
def test_get_credentials_delegates_to_inner(self):
|
||||
provider = AzureEntraIdCredentialProvider.__new__(AzureEntraIdCredentialProvider)
|
||||
mock_inner = MagicMock()
|
||||
mock_inner.get_credentials.return_value = ("user-oid", "jwt-token")
|
||||
provider._inner = mock_inner
|
||||
|
||||
result = provider.get_credentials()
|
||||
assert result == ("user-oid", "jwt-token")
|
||||
|
||||
|
||||
class TestGetAzureCredentialProvider:
|
||||
"""Test get_azure_credential_provider factory."""
|
||||
|
||||
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
|
||||
def test_calls_create_with_correct_scope(self, mock_create):
|
||||
mock_create.return_value = MagicMock()
|
||||
get_azure_credential_provider()
|
||||
|
||||
mock_create.assert_called_once_with(
|
||||
scopes=("https://redis.azure.com/.default",),
|
||||
)
|
||||
|
||||
@patch("redis_entraid.cred_provider.create_from_default_azure_credential")
|
||||
def test_returns_provider_instance(self, mock_create):
|
||||
sentinel = MagicMock()
|
||||
mock_create.return_value = sentinel
|
||||
|
||||
result = get_azure_credential_provider()
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
class TestApplyAzureRedisAuth:
|
||||
"""Test apply_azure_redis_auth params mutation."""
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_removes_username_and_password(self, mock_get_provider):
|
||||
mock_get_provider.return_value = MagicMock()
|
||||
params: dict = {"host": "localhost", "username": "user", "password": "secret"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert "username" not in params
|
||||
assert "password" not in params
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_injects_credential_provider(self, mock_get_provider):
|
||||
sentinel = MagicMock()
|
||||
mock_get_provider.return_value = sentinel
|
||||
params: dict = {"host": "localhost", "username": "u", "password": "p"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert params["credential_provider"] is sentinel
|
||||
|
||||
@patch("extensions.azure.get_azure_credential_provider")
|
||||
def test_handles_missing_username_password(self, mock_get_provider):
|
||||
mock_get_provider.return_value = MagicMock()
|
||||
params: dict = {"host": "localhost"}
|
||||
|
||||
apply_azure_redis_auth(params)
|
||||
|
||||
assert "username" not in params
|
||||
assert "password" not in params
|
||||
assert "credential_provider" in params
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Generated
+18
@@ -1371,6 +1371,7 @@ dependencies = [
|
||||
{ name = "python-socketio" },
|
||||
{ name = "readabilipy" },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
{ name = "redis-entraid" },
|
||||
{ name = "resend" },
|
||||
{ name = "sendgrid" },
|
||||
{ name = "sseclient-py" },
|
||||
@@ -1655,6 +1656,7 @@ requires-dist = [
|
||||
{ name = "python-socketio", specifier = ">=5.13.0,<6.0.0" },
|
||||
{ name = "readabilipy", specifier = "==0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "redis-entraid", specifier = ">=1.2.0,<2.0.0" },
|
||||
{ name = "resend", specifier = ">=2.27.0,<3.0.0" },
|
||||
{ name = "sendgrid", specifier = ">=6.12.5,<7.0.0" },
|
||||
{ name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" },
|
||||
@@ -5819,6 +5821,22 @@ hiredis = [
|
||||
{ name = "hiredis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis-entraid"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-identity" },
|
||||
{ name = "msal" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/a7/0ddaeb27b33c76709e05a12b3bbeefce893c82a3a830146608d6fe620000/redis_entraid-1.2.1.tar.gz", hash = "sha256:a7c479ce46e6edb35bce9dd804d1cad7be99a3330815cfe028a648b486a10b41", size = 9792, upload-time = "2026-06-03T11:38:55.613Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ca/01b8607102de756b270d3f6befeee700bd82dace4303d6f47ce0f53c11b0/redis_entraid-1.2.1-py3-none-any.whl", hash = "sha256:9de7e4a716b156d966a2d6bb5b5ccd64a692db30ae21fe3987f57d233793d558", size = 7967, upload-time = "2026-06-03T11:38:54.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
|
||||
@@ -121,6 +121,7 @@ REDIS_KEEPALIVE=true
|
||||
REDIS_KEEPALIVE_IDLE=30
|
||||
REDIS_KEEPALIVE_INTERVAL=10
|
||||
REDIS_KEEPALIVE_COUNT=10
|
||||
REDIS_USE_AZURE_MANAGED_IDENTITY=false
|
||||
CELERY_BROKER_URL=redis://:difyai123456@redis:6379/1
|
||||
CELERY_BACKEND=redis
|
||||
BROKER_USE_SSL=false
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ConsoleStateFixture> = {}) => {
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
...baseContext,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentsAccessGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setConsoleState()
|
||||
})
|
||||
|
||||
it('renders loading while the workspace is loading', () => {
|
||||
setConsoleState({ isLoadingCurrentWorkspace: true, currentWorkspace: { id: '' } })
|
||||
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('agents')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
|
||||
it('renders children with agent.manage', () => {
|
||||
render(
|
||||
<AgentsAccessGuard>
|
||||
<div>agents</div>
|
||||
</AgentsAccessGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('agents')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 <Loading type="app" />
|
||||
|
||||
if (shouldRedirect) return null
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -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 <AgentsAccessGuard>{children}</AgentsAccessGuard>
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentSelectorContent
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onSelect={vi.fn()}
|
||||
onStartFromScratch={onStartFromScratch}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map((option) => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
{actionOptions.length > 0 && (
|
||||
<div role="presentation" className="border-t border-divider-subtle p-1">
|
||||
{actionOptions.map((option) => (
|
||||
<AgentSelectorActionItem key={option} option={option} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</Combobox>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
@@ -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({
|
||||
|
||||
+60
@@ -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<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div ref={portalContainerRef}>
|
||||
<AgentRosterField
|
||||
agent={{
|
||||
id: 'roster-agent-1',
|
||||
name: 'Roster Agent',
|
||||
role: 'Shared roster agent',
|
||||
}}
|
||||
portalContainerRef={portalContainerRef}
|
||||
onChange={vi.fn()}
|
||||
onMakeCopy={vi.fn()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Harness />)
|
||||
}
|
||||
|
||||
function renderInlineRosterField() {
|
||||
function Harness() {
|
||||
const portalContainerRef = useRef<HTMLDivElement>(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()
|
||||
|
||||
+21
@@ -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(<EditInConsoleLink agentId="agent-1" canManageAgents />)
|
||||
|
||||
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(<EditInConsoleLink agentId="agent-1" canManageAgents={false} />)
|
||||
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /editInConsole/ })).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
})
|
||||
+4
@@ -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 (
|
||||
<DropdownMenu modal={false}>
|
||||
|
||||
@@ -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 (
|
||||
<Drawer
|
||||
@@ -251,17 +252,7 @@ function AgentRosterDrawer({
|
||||
{!isSetup && showDetailActions && (
|
||||
<div className="flex h-8 gap-2 pl-1">
|
||||
{showConsoleLink && (
|
||||
<Link
|
||||
href={getAgentDetailPath(agent.id, 'configure')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium whitespace-nowrap text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-external-link-line size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t(($) => $[`${i18nPrefix}.roster.editInConsole`], { ns: 'workflow' })}
|
||||
</span>
|
||||
</Link>
|
||||
<EditInConsoleLink agentId={agent.id} canManageAgents={canManageAgents} />
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAgentDetailPath } from '@/features/agent-v2/agent-detail/routes'
|
||||
import Link from '@/next/link'
|
||||
|
||||
const layoutClassName = 'min-w-0 flex-1 gap-1.5 px-3'
|
||||
|
||||
export function EditInConsoleLink({
|
||||
agentId,
|
||||
canManageAgents,
|
||||
}: {
|
||||
agentId: string
|
||||
canManageAgents: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const label = t(($) => $['nodes.agent.roster.editInConsole'], { ns: 'workflow' })
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span aria-hidden className="i-ri-external-link-line size-4 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (canManageAgents) {
|
||||
return (
|
||||
<Link
|
||||
className={cn(buttonVariants({ className: layoutClassName }))}
|
||||
href={getAgentDetailPath(agentId, 'configure')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button className={layoutClassName} disabled focusableWhenDisabled>
|
||||
{content}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t(($) => $['nodes.agent.roster.editInConsoleDisabled'], { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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<DeploymentCase>([
|
||||
{
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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, '')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+33
@@ -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<typeof import('@/config')>()),
|
||||
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(<CommunityEditionTip tip={tip} />)
|
||||
|
||||
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(<CommunityEditionTip tip={tip} />)
|
||||
|
||||
expect(screen.queryByLabelText(tip)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={tip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement={placement}
|
||||
popupClassName={cn('px-3 py-2 system-xs-regular text-text-tertiary', popupClassName)}
|
||||
>
|
||||
{tip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
<h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary">
|
||||
{t(($) => $['agentDetail.configure.title'])}
|
||||
</h2>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionIsolationTip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
popupClassName="max-w-[320px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionIsolationTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<CommunityEditionTip tip={communityEditionIsolationTip} popupClassName="max-w-[320px]" />
|
||||
{isBuildDraftActive && (
|
||||
<span className="flex min-w-[18px] shrink-0 items-center justify-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 py-0.75 system-2xs-medium-uppercase text-text-accent-secondary">
|
||||
{t(($) => $['agentDetail.configure.buildDraft.modeBadge'])}
|
||||
|
||||
@@ -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() {
|
||||
<div className="min-w-0 truncate system-md-medium text-text-secondary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.title'])}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionBuildModeTip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionBuildModeTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<CommunityEditionTip
|
||||
tip={communityEditionBuildModeTip}
|
||||
placement="top"
|
||||
popupClassName="max-w-[340px]"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.description'])}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "إدارة Agents",
|
||||
"api_extension.manage": "إدارة إعدادات امتداد API",
|
||||
"app.access_config": "تكوين أذونات الوصول إلى التطبيق",
|
||||
"app.acl.access_config": "تكوين أذونات الوصول إلى التطبيق",
|
||||
|
||||
@@ -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": "إعداد الوكيل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "مدیریت Agents",
|
||||
"api_extension.manage": "مدیریت پیکربندی افزونه API",
|
||||
"app.access_config": "پیکربندی مجوزهای دسترسی برنامه",
|
||||
"app.acl.access_config": "پیکربندی مجوزهای دسترسی برنامه",
|
||||
|
||||
@@ -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": "پیکربندی عامل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "Agents प्रबंधित करें",
|
||||
"api_extension.manage": "API एक्सटेंशन कॉन्फ़िगरेशन प्रबंधित करें",
|
||||
"app.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें",
|
||||
"app.acl.access_config": "ऐप एक्सेस अनुमतियाँ कॉन्फ़िगर करें",
|
||||
|
||||
@@ -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": "एजेंट सेटअप",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "Agentsを管理",
|
||||
"api_extension.manage": "API拡張設定を管理",
|
||||
"app.access_config": "アプリアクセス権限を設定",
|
||||
"app.acl.access_config": "アプリアクセス権限を設定",
|
||||
|
||||
@@ -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 セットアップ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "Agents 관리",
|
||||
"api_extension.manage": "API 확장 구성 관리",
|
||||
"app.access_config": "앱 접근 권한 구성",
|
||||
"app.acl.access_config": "앱 접근 권한 구성",
|
||||
|
||||
@@ -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 설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "Управление Agents",
|
||||
"api_extension.manage": "Управление конфигурацией API-расширений",
|
||||
"app.access_config": "Настройка прав доступа к приложению",
|
||||
"app.acl.access_config": "Настройка прав доступа к приложению",
|
||||
|
||||
@@ -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": "Настройка агента",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "จัดการ Agents",
|
||||
"api_extension.manage": "จัดการการกําหนดค่าส่วนขยาย API",
|
||||
"app.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป",
|
||||
"app.acl.access_config": "กําหนดค่าสิทธิ์การเข้าถึงแอป",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "Керування Agents",
|
||||
"api_extension.manage": "Керування конфігурацією розширення API",
|
||||
"app.access_config": "Налаштування дозволів доступу до застосунку",
|
||||
"app.acl.access_config": "Налаштування дозволів доступу до застосунку",
|
||||
|
||||
@@ -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": "Налаштування агента",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"agent.manage": "管理 Agents",
|
||||
"api_extension.manage": "管理API扩展",
|
||||
"app.access_config": "配置应用访问权限",
|
||||
"app.acl.access_config": "配置应用访问权限",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user