Compare commits

...

2 Commits

Author SHA1 Message Date
fatelei
ce8ce7626e feat: agent backend api add rbac 2026-08-31 22:05:53 +08:00
fatelei
39093c0749 feat: agent support rbac 2026-08-31 17:18:15 +08:00
12 changed files with 802 additions and 40 deletions

View File

@@ -8,6 +8,7 @@ from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.agent import Agent
from models.dataset import Dataset
from models.model import App
from services.agent.roster_service import AgentRosterService
@@ -129,6 +130,15 @@ def _is_resource_owned_by_current_user(
)
return maintainer == account_id
if resource_type == RBACResourceScope.AGENT:
created_by = db.session.scalar(
select(Agent.created_by).where(
Agent.id == resource_id,
Agent.tenant_id == tenant_id,
)
)
return created_by == account_id
return False
@@ -178,4 +188,11 @@ def _extract_resource_id(
raise NotFound("Dataset not found for pipeline")
return str(dataset.id) # pyrefly: ignore[unnecessary-type-conversion]
raise ValueError("Missing dataset_id or pipeline_id in request path")
if resource_type == RBACResourceScope.AGENT:
agent_id = matched_args.get("agent_id") or matched_args.get("resource_id")
if agent_id:
return str(agent_id)
raise ValueError("Missing agent_id in request path")
raise ValueError(f"Unknown resource_type: {resource_type}")

View File

@@ -94,7 +94,6 @@ class WorkflowAgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@with_current_user_id
@with_current_tenant_id
@with_session
@@ -134,7 +133,6 @@ class WorkflowAgentComposerCopyFromRosterApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@with_current_user_id
@with_current_tenant_id
@with_session
@@ -246,8 +244,6 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_user_id
@with_current_tenant_id
@with_session
@@ -479,7 +475,6 @@ 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
@@ -512,7 +507,6 @@ class AgentComposerApi(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
def get(self, session: Session, tenant_id: str, agent_id: UUID):
@@ -527,8 +521,6 @@ class AgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_user_id
@with_current_tenant_id
@with_session

View File

@@ -7,6 +7,7 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from configs import dify_config
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
@@ -78,9 +79,11 @@ from services.agent.observability_service import (
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
from services.feature_service import FeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
AgentPublicationStatus = Literal["published", "drafts"]
@@ -429,6 +432,14 @@ def _serialize_agent_app_detail(
payload["debug_conversation_message_count"] = message_count
payload["role"] = agent.role or ""
payload["access_ready"] = agent_has_workflow_callable_active_snapshot(session=session, agent=agent)
if dify_config.RBAC_ENABLED:
permission_keys_map = enterprise_rbac_service.RBACService.AgentPermissions.batch_get(
str(app_model.tenant_id),
current_user.id,
[str(agent.id)],
session=session,
)
payload["permission_keys"] = permission_keys_map.get(str(agent.id), [])
return payload
@@ -454,6 +465,15 @@ def _serialize_agent_app_pagination(
tenant_id=tenant_id,
app_ids=app_ids,
)
agent_ids = [str(agent.id) for agent in agents_by_app_id.values()]
permission_keys_by_agent_id: dict[str, list[str]] = {}
if dify_config.RBAC_ENABLED:
permission_keys_by_agent_id = enterprise_rbac_service.RBACService.AgentPermissions.batch_get(
tenant_id,
current_user.id,
agent_ids,
session=session,
)
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
@@ -496,6 +516,7 @@ def _serialize_agent_app_pagination(
item["id"] = agent.id
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
item["role"] = agent.role or ""
item["permission_keys"] = permission_keys_by_agent_id.get(str(agent.id), [])
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
item["reference_count"] = reference_counts_by_agent_id.get(agent.id, 0)
published_references = published_references_by_agent_id.get(agent.id, [])
@@ -567,6 +588,29 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
return response.model_dump(mode="json")
def _initialize_created_agent_rbac_access(
session: Session,
*,
tenant_id: str,
account_id: str,
app_model: App,
) -> None:
if not dify_config.RBAC_ENABLED:
return
agent = _agent_roster_service(session).get_app_backing_agent(tenant_id=tenant_id, app_id=str(app_model.id))
if not agent:
raise AgentNotFoundError()
enterprise_rbac_service.RBACService.AgentAccess.replace_whitelist(
tenant_id,
account_id,
str(agent.id),
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
)
initialize_created_app_rbac_access_task.delay(tenant_id, account_id, agent_id=str(agent.id))
def _agent_observability_service(session: Session) -> AgentObservabilityService:
return AgentObservabilityService(session)
@@ -607,7 +651,6 @@ 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
@@ -667,7 +710,7 @@ class AgentAppListApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_CREATE, resource_required=False)
@with_current_user
@with_current_tenant_id
@with_session
@@ -684,6 +727,12 @@ class AgentAppListApi(Resource):
)
app = AppService().create_app(current_tenant_id, params, current_user, session=session)
_initialize_created_agent_rbac_access(
session,
tenant_id=current_tenant_id,
account_id=current_user.id,
app_model=app,
)
return _serialize_agent_app_detail(session, app, current_user=current_user), 201
@@ -693,7 +742,7 @@ class AgentAppApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_PREVIEW)
@enterprise_license_required
@with_current_user
@with_current_tenant_id
@@ -710,7 +759,7 @@ class AgentAppApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session
@@ -743,7 +792,7 @@ class AgentAppApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_DELETE)
@with_current_tenant_id
@with_session
def delete(self, session: Session, tenant_id: str, agent_id: UUID):
@@ -764,6 +813,7 @@ class AgentDebugConversationRefreshApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_TEST_AND_RUN)
@with_current_user
@with_current_tenant_id
@with_session
@@ -789,7 +839,7 @@ class AgentPublishApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_RELEASE_AND_VERSION)
@with_current_user
@with_current_tenant_id
@with_session
@@ -819,7 +869,7 @@ class AgentBuildDraftCheckoutApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -849,6 +899,7 @@ class AgentBuildDraftApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -866,6 +917,7 @@ class AgentBuildDraftApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session
@@ -891,6 +943,7 @@ class AgentBuildDraftApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -910,7 +963,7 @@ class AgentBuildDraftApplyApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_EDIT)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -933,7 +986,7 @@ class AgentAppCopyApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_CREATE, resource_required=False)
@with_current_user
@with_current_tenant_id
@with_session
@@ -957,6 +1010,12 @@ class AgentAppCopyApi(Resource):
icon=req_data.icon,
icon_background=req_data.icon_background,
)
_initialize_created_agent_rbac_access(
session,
tenant_id=tenant_id,
account_id=current_user.id,
app_model=copied_app,
)
return _serialize_agent_app_detail(session, copied_app, current_user=current_user), 201
@@ -966,7 +1025,7 @@ class AgentApiAccessApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_ACCESS_POINT_VIEW)
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
@@ -983,8 +1042,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)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_ACCESS_POINT_MANAGE)
@with_current_tenant_id
@with_session
@model_validate(AgentApiStatusPayload)
@@ -1002,10 +1060,9 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_ACCESS_POINT_VIEW)
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
@@ -1015,8 +1072,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)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_ACCESS_POINT_MANAGE)
@with_session
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
@@ -1035,8 +1091,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)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_ACCESS_POINT_MANAGE)
@with_session
def delete(
self,
@@ -1081,7 +1136,7 @@ class AgentLogsApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_LOG_MANAGE)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -1120,7 +1175,7 @@ class AgentLogMessagesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_LOG_MANAGE)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -1159,7 +1214,7 @@ class AgentLogSourcesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_LOG_MANAGE)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -1180,7 +1235,7 @@ class AgentStatisticsSummaryApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_MONITOR)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
@@ -1213,7 +1268,7 @@ class AgentRosterVersionsApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_RELEASE_AND_VERSION)
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID):
@@ -1229,7 +1284,7 @@ class AgentRosterVersionDetailApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_RELEASE_AND_VERSION)
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, tenant_id: str, agent_id: UUID, version_id: UUID):
@@ -1250,7 +1305,7 @@ class AgentRosterVersionRestoreApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.AGENT, RBACPermission.AGENT_RELEASE_AND_VERSION)
@with_current_user
@with_current_tenant_id
@with_session

View File

@@ -70,16 +70,19 @@ _LEGACY_ROLE_PERMISSION_KEYS: dict[str, list[str]] = {
*svc._LEGACY_WORKSPACE_OWNER_KEYS,
*svc._LEGACY_APP_OWNER_KEYS,
*svc._LEGACY_DATASET_OWNER_KEYS,
*svc._LEGACY_AGENT_OWNER_KEYS,
],
"admin": [
*svc._LEGACY_WORKSPACE_ADMIN_KEYS,
*svc._LEGACY_APP_ADMIN_KEYS,
*svc._LEGACY_DATASET_ADMIN_KEYS,
*svc._LEGACY_AGENT_ADMIN_KEYS,
],
"editor": [
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
*svc._LEGACY_APP_EDITOR_KEYS,
*svc._LEGACY_DATASET_EDITOR_KEYS,
*svc._LEGACY_AGENT_EDITOR_KEYS,
],
"normal": [
*svc._LEGACY_WORKSPACE_NORMAL_KEYS,
@@ -797,6 +800,43 @@ class RBACDatasetUserAccessPoliciesApi(Resource):
return _dump(result)
# ---------------------------------------------------------------------------
# Per-agent access (Agent Access Config).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/agents/<uuid:agent_id>/whitelist_config")
class RBACAgentWhitelistConfigApi(Resource):
@login_required
@console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelistConfig.__name__])
def get(self, agent_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AgentAccess.whitelist_config(tenant_id, account_id, str(agent_id)))
@console_ns.route("/workspaces/current/rbac/agents/<uuid:agent_id>/whitelist")
class RBACAgentWhitelistApi(Resource):
@login_required
@console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__])
def get(self, agent_id):
tenant_id, account_id = _current_ids()
return _dump(svc.RBACService.AgentAccess.whitelist(tenant_id, account_id, str(agent_id)))
@login_required
@console_ns.expect(console_ns.models[_ResourceAccessScopeRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[svc.ResourceWhitelist.__name__])
def put(self, agent_id):
tenant_id, account_id = _current_ids()
request = _payload(_ResourceAccessScopeRequest)
result = svc.RBACService.AgentAccess.replace_whitelist(
tenant_id,
account_id,
str(agent_id),
svc.ReplaceMemberBindings(automatic_include_workspace_members=request.automatic_include_workspace_members),
)
return _dump(result)
@console_ns.route("/workspaces/current/rbac/datasets/<uuid:dataset_id>/users/<uuid:target_account_id>/access-policies")
class RBACDatasetUserAccessPolicyAssignmentApi(Resource):
@login_required

View File

@@ -5,10 +5,11 @@ class RBACResourceScope(StrEnum):
"""Resource scopes accepted by the ``rbac_permission_required`` decorator.
``WORKSPACE`` denotes a workspace-level check that carries no concrete
resource id; ``APP`` and ``DATASET`` are resource-scoped checks.
resource id; ``APP``, ``DATASET`` and ``AGENT`` are resource-scoped checks.
"""
APP = "app"
AGENT = "agent"
DATASET = "dataset"
WORKSPACE = "workspace"
@@ -41,6 +42,19 @@ class RBACPermission(StrEnum):
APP_DELETE = "app_delete"
APP_ACCESS_CONFIG = "app_access_config"
AGENT_CREATE = "agent_create"
AGENT_PREVIEW = "agent_preview"
AGENT_EDIT = "agent_edit"
AGENT_TEST_AND_RUN = "agent_test_and_run"
AGENT_RELEASE_AND_VERSION = "agent_release_and_version"
AGENT_ACCESS_POINT_VIEW = "agent_access_point_view"
AGENT_ACCESS_POINT_MANAGE = "agent_access_point_manage"
AGENT_LOG_MANAGE = "agent_log_manage"
AGENT_MONITOR = "agent_monitor"
AGENT_ACCESS_CONFIG = "agent_access_config"
AGENT_IMPORT_EXPORT_DSL = "agent_import_export_dsl"
AGENT_DELETE = "agent_delete"
DATASET_PREVIEW = "dataset_preview"
DATASET_READONLY = "dataset_readonly"
DATASET_EDIT = "dataset_edit"

View File

@@ -58,6 +58,7 @@ class RBACResourceType(StrEnum):
APP = "app"
DATASET = "dataset"
AGENT = "agent"
class RBACRoleType(StrEnum):
@@ -339,6 +340,12 @@ class AppendDatasetWhitelistMembersBatchItem(_RBACModel):
policy_id: str
class AppendAgentWhitelistMembersBatchItem(_RBACModel):
agent_id: str
account_ids: list[str] = Field(default_factory=list)
policy_id: str
class MemberRolesResponse(_RBACModel):
account_id: str
roles: list[RBACRole] = Field(default_factory=list)
@@ -369,6 +376,7 @@ class MyPermissionsResponse(_RBACModel):
workspace: WorkspacePermissionSnapshot = Field(default_factory=WorkspacePermissionSnapshot)
app: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot)
dataset: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot)
agent: ResourcePermissionSnapshot = Field(default_factory=ResourcePermissionSnapshot)
# Fallback permission snapshots for legacy Dify tenant roles when external RBAC is disabled.
@@ -405,6 +413,7 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"tool.manage",
"mcp.manage",
"agent.manage",
"agent.create",
]
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
@@ -437,6 +446,7 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"tool.manage",
"mcp.manage",
"agent.manage",
"agent.create",
]
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
@@ -456,6 +466,7 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"snippets.create_and_modify",
"tool.manage",
"agent.manage",
"agent.create",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -576,41 +587,104 @@ _LEGACY_DATASET_DATASET_OPERATOR_KEYS: list[str] = [
"dataset.acl.pipeline_release",
]
_LEGACY_AGENT_OWNER_KEYS: list[str] = [
"agent.acl.preview",
"agent.acl.edit",
"agent.acl.test_and_run",
"agent.acl.release_and_version",
"agent.acl.access_point_view",
"agent.acl.access_point_manage",
"agent.acl.log_manage",
"agent.acl.monitor",
"agent.acl.access_config",
"agent.acl.import_export_dsl",
"agent.acl.delete",
]
_LEGACY_AGENT_ADMIN_KEYS: list[str] = [
"agent.acl.preview",
"agent.acl.edit",
"agent.acl.test_and_run",
"agent.acl.release_and_version",
"agent.acl.access_point_view",
"agent.acl.access_point_manage",
"agent.acl.log_manage",
"agent.acl.monitor",
"agent.acl.access_config",
"agent.acl.import_export_dsl",
"agent.acl.delete",
]
_LEGACY_AGENT_EDITOR_KEYS: list[str] = [
"agent.acl.preview",
"agent.acl.edit",
"agent.acl.test_and_run",
"agent.acl.release_and_version",
"agent.acl.access_point_view",
"agent.acl.access_point_manage",
"agent.acl.log_manage",
"agent.acl.monitor",
"agent.acl.import_export_dsl",
]
_LEGACY_AGENT_EDITOR_DEFAULT_KEYS: list[str] = [
key for key in _LEGACY_AGENT_EDITOR_KEYS if key != "agent.acl.log_manage"
]
_LEGACY_AGENT_NORMAL_KEYS: list[str] = [
"agent.acl.preview",
"agent.acl.access_point_view",
]
_LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = {
TenantAccountRole.OWNER: {
"workspace": _LEGACY_WORKSPACE_OWNER_KEYS,
"app": _LEGACY_APP_OWNER_KEYS,
"dataset": _LEGACY_DATASET_OWNER_KEYS,
"agent": _LEGACY_AGENT_OWNER_KEYS,
},
TenantAccountRole.ADMIN: {
"workspace": _LEGACY_WORKSPACE_ADMIN_KEYS,
"app": _LEGACY_APP_ADMIN_KEYS,
"dataset": _LEGACY_DATASET_ADMIN_KEYS,
"agent": _LEGACY_AGENT_ADMIN_KEYS,
},
TenantAccountRole.EDITOR: {
"workspace": _LEGACY_WORKSPACE_EDITOR_KEYS,
"app": _LEGACY_APP_EDITOR_KEYS,
"dataset": _LEGACY_DATASET_EDITOR_KEYS,
"agent": _LEGACY_AGENT_EDITOR_DEFAULT_KEYS,
},
TenantAccountRole.NORMAL: {
"workspace": _LEGACY_WORKSPACE_NORMAL_KEYS,
"app": _LEGACY_APP_NORMAL_KEYS,
"agent": _LEGACY_AGENT_NORMAL_KEYS,
},
TenantAccountRole.DATASET_OPERATOR: {
"workspace": _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
"dataset": _LEGACY_DATASET_DATASET_OPERATOR_KEYS,
"agent": _LEGACY_AGENT_NORMAL_KEYS,
},
}
_LEGACY_MEMBER_ROLE_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = {
**_LEGACY_MY_PERMISSIONS,
TenantAccountRole.EDITOR: {
**_LEGACY_MY_PERMISSIONS[TenantAccountRole.EDITOR],
"agent": _LEGACY_AGENT_EDITOR_KEYS,
},
}
def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]:
permissions = _LEGACY_MY_PERMISSIONS.get(role, {})
permissions = _LEGACY_MEMBER_ROLE_PERMISSIONS.get(role, {})
return list(
dict.fromkeys(
[
*permissions.get("workspace", []),
*permissions.get("app", []),
*permissions.get("dataset", []),
*permissions.get("agent", []),
]
)
)
@@ -667,6 +741,7 @@ def _legacy_my_permissions(tenant_id: str, account_id: str | None, *, session: S
workspace=WorkspacePermissionSnapshot(permission_keys=list(permissions.get("workspace", []))),
app=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("app", []))),
dataset=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("dataset", []))),
agent=ResourcePermissionSnapshot(default_permission_keys=list(permissions.get("agent", []))),
)
@@ -681,6 +756,8 @@ def _legacy_resource_permission_keys_batch(
snapshot = _legacy_my_permissions(tenant_id, account_id, session=session)
if resource_type == RBACResourceType.APP:
permission_keys = snapshot.app.default_permission_keys
elif resource_type == RBACResourceType.AGENT:
permission_keys = snapshot.agent.default_permission_keys
else:
permission_keys = snapshot.dataset.default_permission_keys
return {str(resource_id): list(permission_keys) for resource_id in resource_ids}
@@ -1605,6 +1682,81 @@ class RBACService:
)
return AccessMatrixItem.model_validate(data or {})
# ------------------------------------------------------------------
# Per-agent access.
# ------------------------------------------------------------------
class AgentAccess:
@staticmethod
def replace_user_access_policies(
tenant_id: str,
account_id: str | None,
agent_id: str,
target_account_id: str | None,
payload: ReplaceUserAccessPolicies,
) -> ReplaceUserAccessPoliciesResponse:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/agents/user-access-policies",
tenant_id=tenant_id,
account_id=account_id,
params={"agent_id": agent_id, "account_id": target_account_id},
json=payload.model_dump(mode="json", exclude_unset=True),
)
return ReplaceUserAccessPoliciesResponse.model_validate(data or {})
@staticmethod
def whitelist(tenant_id: str, account_id: str | None, agent_id: str) -> ResourceWhitelist:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/agents/whitelist",
tenant_id=tenant_id,
account_id=account_id,
params={"agent_id": agent_id},
)
return ResourceWhitelist.model_validate(data or {})
@staticmethod
def whitelist_config(tenant_id: str, account_id: str | None, agent_id: str) -> ResourceWhitelistConfig:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/agents/whitelist",
tenant_id=tenant_id,
account_id=account_id,
params={"agent_id": agent_id},
)
return ResourceWhitelistConfig.model_validate(data or {})
@staticmethod
def replace_whitelist(
tenant_id: str,
account_id: str | None,
agent_id: str,
payload: ReplaceMemberBindings,
) -> ResourceWhitelist:
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/agents/whitelist",
tenant_id=tenant_id,
account_id=account_id,
params={"agent_id": agent_id},
json=payload.model_dump(mode="json"),
)
return ResourceWhitelist.model_validate(data or {})
@staticmethod
def append_whitelist_members_batch(
tenant_id: str,
account_id: str | None,
data: Sequence[AppendAgentWhitelistMembersBatchItem],
) -> None:
_inner_call(
"POST",
f"{_INNER_PREFIX}/agents/whitelist/members/batch",
tenant_id=tenant_id,
account_id=account_id,
json={"data": [item.model_dump(mode="json") for item in data]},
)
# ------------------------------------------------------------------
# Workspace-level access (screenshot 2: Settings > Access Rules).
# ------------------------------------------------------------------
@@ -1957,6 +2109,30 @@ class RBACService:
)
return _parse_resource_permission_keys_batch(data, resource_id_key="dataset_id")
class AgentPermissions:
@staticmethod
def batch_get(
tenant_id: str,
account_id: str | None,
agent_ids: list[str],
*,
session: Session,
) -> dict[str, list[str]]:
if not agent_ids:
return {}
if not dify_config.RBAC_ENABLED:
return _legacy_resource_permission_keys_batch(
tenant_id, account_id, agent_ids, RBACResourceType.AGENT, session=session
)
data = _inner_call(
"POST",
f"{_INNER_PREFIX}/agents/permission-keys/batch",
tenant_id=tenant_id,
account_id=account_id,
json={"agent_ids": agent_ids},
)
return _parse_resource_permission_keys_batch(data, resource_id_key="agent_id")
class MyPermissions:
@staticmethod
def get(
@@ -2001,7 +2177,12 @@ def _parse_resource_permission_keys_batch(data: Any, *, resource_id_key: str) ->
if items is None:
items = data.get("items")
if items is None:
items = data.get("apps") if resource_id_key == "app_id" else data.get("datasets")
collection_key_by_resource_id_key = {
"app_id": "apps",
"dataset_id": "datasets",
"agent_id": "agents",
}
items = data.get(collection_key_by_resource_id_key.get(resource_id_key, ""))
if isinstance(items, dict):
items = [{"resource_id": key, "permission_keys": value} for key, value in items.items()]
elif isinstance(data, list):

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from configs import dify_config
from extensions.ext_database import db
from models import App, Dataset, TenantAccountJoin, TenantAccountRole
from models.agent import Agent, AgentScope, AgentStatus
from services.account_service import TenantService
from services.enterprise import rbac_service as enterprise_rbac_service
@@ -68,6 +69,32 @@ def _iter_resource_config_batches(
]
last_dataset_id = dataset_ids[-1]
last_agent_id: str | None = None
while True:
stmt = (
select(Agent.id)
.where(
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.id.asc())
.limit(batch_size)
)
if last_agent_id:
stmt = stmt.where(Agent.id > last_agent_id)
agent_ids = [str(agent_id) for agent_id in db.session().scalars(stmt).all()]
if not agent_ids:
break
yield [
enterprise_rbac_service.ResourceWhitelistConfigResource(
resource_type=enterprise_rbac_service.RBACResourceType.AGENT,
resource_id=agent_id,
)
for agent_id in agent_ids
]
last_agent_id = agent_ids[-1]
def _chunks[T](items: list[T], chunk_size: int) -> Iterator[list[T]]:
for index in range(0, len(items), chunk_size):
@@ -76,7 +103,12 @@ def _chunks[T](items: list[T], chunk_size: int) -> Iterator[list[T]]:
@shared_task(queue=APP_RBAC_QUEUE, bind=True, max_retries=3, default_retry_delay=60)
def initialize_created_app_rbac_access_task(
self, tenant_id: str, account_id: str, app_id: str | None = None, dataset_id: str | None = None
self,
tenant_id: str,
account_id: str,
app_id: str | None = None,
dataset_id: str | None = None,
agent_id: str | None = None,
) -> None:
"""Grant the default app policy to current workspace members.
@@ -115,11 +147,25 @@ def initialize_created_app_rbac_access_task(
account_ids=account_ids,
),
)
elif agent_id is not None:
enterprise_rbac_service.RBACService.AgentAccess.replace_user_access_policies(
tenant_id=tenant_id,
account_id=account_id,
agent_id=agent_id,
target_account_id=None,
payload=enterprise_rbac_service.ReplaceUserAccessPolicies(
access_policy_ids=[APP_RBAC_DEFAULT_ACCESS_POLICY_ID],
account_ids=account_ids,
),
)
except Exception as exc:
logger.exception(
"Failed to initialize app RBAC access; retrying: tenant_id=%s app_id=%s attempt=%s",
"Failed to initialize app RBAC access; retrying: "
"tenant_id=%s app_id=%s dataset_id=%s agent_id=%s attempt=%s",
tenant_id,
app_id,
dataset_id,
agent_id,
self.request.retries + 1,
)
raise self.retry(exc=exc)
@@ -148,6 +194,7 @@ def sync_joined_workspace_member_rbac_access_task(
app_ids: list[str] = []
dataset_ids: list[str] = []
agent_ids: list[str] = []
for resources in _iter_resource_config_batches(tenant_id, APP_RBAC_RESOURCE_CONFIG_BATCH_SIZE):
configs = enterprise_rbac_service.RBACService.ResourceWhitelistConfigs.batch_get(
tenant_id=tenant_id,
@@ -161,6 +208,8 @@ def sync_joined_workspace_member_rbac_access_task(
app_ids.append(config.resource_id)
elif config.resource_type == enterprise_rbac_service.RBACResourceType.DATASET:
dataset_ids.append(config.resource_id)
elif config.resource_type == enterprise_rbac_service.RBACResourceType.AGENT:
agent_ids.append(config.resource_id)
for app_id_batch in _chunks(app_ids, APP_RBAC_MEMBER_APPEND_BATCH_SIZE):
enterprise_rbac_service.RBACService.AppAccess.append_whitelist_members_batch(
@@ -176,6 +225,20 @@ def sync_joined_workspace_member_rbac_access_task(
],
)
for agent_id_batch in _chunks(agent_ids, APP_RBAC_MEMBER_APPEND_BATCH_SIZE):
enterprise_rbac_service.RBACService.AgentAccess.append_whitelist_members_batch(
tenant_id=tenant_id,
account_id=actor_account_id,
data=[
enterprise_rbac_service.AppendAgentWhitelistMembersBatchItem(
agent_id=agent_id,
account_ids=[member_account_id],
policy_id=APP_RBAC_DEFAULT_ACCESS_POLICY_ID,
)
for agent_id in agent_id_batch
],
)
for dataset_id_batch in _chunks(dataset_ids, APP_RBAC_MEMBER_APPEND_BATCH_SIZE):
enterprise_rbac_service.RBACService.DatasetAccess.append_whitelist_members_batch(
tenant_id=tenant_id,

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from inspect import getsource, unwrap
from inspect import getclosurevars, getsource, unwrap
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock, Mock, call
@@ -15,6 +15,7 @@ from controllers.console.agent import roster as roster_controller
from controllers.console.agent.composer import (
AgentComposerApi,
AgentComposerCandidatesApi,
SnippetAgentComposerSaveToRosterApi,
WorkflowAgentComposerApi,
WorkflowAgentComposerCandidatesApi,
WorkflowAgentComposerCopyFromRosterApi,
@@ -76,6 +77,30 @@ from services.entities.agent_entities import (
)
def _rbac_decorators(method: object) -> list[dict[str, object]]:
decorators: list[dict[str, object]] = []
current = method
while hasattr(current, "__wrapped__"):
closure = getclosurevars(cast(Any, current)).nonlocals
if "resource_type" in closure and "scene" in closure:
decorators.append(
{
"resource_type": closure["resource_type"],
"scene": closure["scene"],
"resource_required": closure.get("resource_required", True),
}
)
current = cast(Any, current).__wrapped__
return decorators
def _assert_rbac_decorators(method: object, expected: list[tuple[object, object, bool]]) -> None:
assert _rbac_decorators(method) == [
{"resource_type": resource_type, "scene": scene, "resource_required": resource_required}
for resource_type, scene, resource_required in expected
]
def _persist_conversation_message(
session: Session,
*,
@@ -301,6 +326,69 @@ def test_agent_app_write_routes_do_not_reuse_app_billing_quota() -> None:
assert '@cloud_edition_billing_resource_check("apps")' not in getsource(route_class)
def test_agent_roster_routes_use_agent_rbac_scenes() -> None:
workspace = roster_controller.RBACResourceScope.WORKSPACE
agent = roster_controller.RBACResourceScope.AGENT
permission = roster_controller.RBACPermission
cases = [
(AgentAppListApi.get, []),
(AgentAppListApi.post, [(workspace, permission.AGENT_CREATE, False)]),
(AgentAppApi.get, [(agent, permission.AGENT_PREVIEW, True)]),
(AgentAppApi.put, [(agent, permission.AGENT_EDIT, True)]),
(AgentAppApi.delete, [(agent, permission.AGENT_DELETE, True)]),
(AgentDebugConversationRefreshApi.post, [(agent, permission.AGENT_TEST_AND_RUN, True)]),
(AgentPublishApi.post, [(agent, permission.AGENT_RELEASE_AND_VERSION, True)]),
(AgentBuildDraftCheckoutApi.post, [(agent, permission.AGENT_EDIT, True)]),
(AgentBuildDraftApi.get, [(agent, permission.AGENT_EDIT, True)]),
(AgentBuildDraftApi.put, [(agent, permission.AGENT_EDIT, True)]),
(AgentBuildDraftApi.delete, [(agent, permission.AGENT_EDIT, True)]),
(AgentBuildDraftApplyApi.post, [(agent, permission.AGENT_EDIT, True)]),
(AgentAppCopyApi.post, [(workspace, permission.AGENT_CREATE, False)]),
(AgentApiAccessApi.get, [(agent, permission.AGENT_ACCESS_POINT_VIEW, True)]),
(AgentApiStatusApi.post, [(agent, permission.AGENT_ACCESS_POINT_MANAGE, True)]),
(AgentApiKeyListApi.get, [(agent, permission.AGENT_ACCESS_POINT_VIEW, True)]),
(AgentApiKeyListApi.post, [(agent, permission.AGENT_ACCESS_POINT_MANAGE, True)]),
(AgentApiKeyApi.delete, [(agent, permission.AGENT_ACCESS_POINT_MANAGE, True)]),
(AgentLogsApi.get, [(agent, permission.AGENT_LOG_MANAGE, True)]),
(AgentLogMessagesApi.get, [(agent, permission.AGENT_LOG_MANAGE, True)]),
(AgentLogSourcesApi.get, [(agent, permission.AGENT_LOG_MANAGE, True)]),
(AgentStatisticsSummaryApi.get, [(agent, permission.AGENT_MONITOR, True)]),
(AgentRosterVersionsApi.get, [(agent, permission.AGENT_RELEASE_AND_VERSION, True)]),
(AgentRosterVersionDetailApi.get, [(agent, permission.AGENT_RELEASE_AND_VERSION, True)]),
(AgentRosterVersionRestoreApi.post, [(agent, permission.AGENT_RELEASE_AND_VERSION, True)]),
]
for method, expected in cases:
_assert_rbac_decorators(method, expected)
def test_agent_composer_routes_use_agent_rbac_scenes() -> None:
workspace = composer_controller.RBACResourceScope.WORKSPACE
agent = composer_controller.RBACResourceScope.AGENT
app = composer_controller.RBACResourceScope.APP
permission = composer_controller.RBACPermission
cases = [
(
WorkflowAgentComposerSaveToRosterApi.post,
[(app, permission.APP_EDIT, True), (workspace, permission.AGENT_CREATE, False)],
),
(
SnippetAgentComposerSaveToRosterApi.post,
[
(workspace, permission.SNIPPETS_CREATE_AND_MODIFY, False),
(workspace, permission.AGENT_CREATE, False),
],
),
(AgentComposerApi.get, [(agent, permission.AGENT_EDIT, True)]),
(AgentComposerApi.put, [(agent, permission.AGENT_EDIT, True)]),
(AgentComposerValidateApi.post, [(agent, permission.AGENT_EDIT, True)]),
(AgentComposerCandidatesApi.get, [(agent, permission.AGENT_EDIT, True)]),
]
for method, expected in cases:
_assert_rbac_decorators(method, expected)
@pytest.fixture
def account_id() -> str:
return "account-1"
@@ -310,6 +398,7 @@ def test_agent_app_list_and_create_use_agent_route(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
) -> None:
captured: dict[str, object] = {}
monkeypatch.setattr(roster_controller.dify_config, "RBAC_ENABLED", True)
class FakeAppService:
def get_app(self, app_obj: object, *, session: object) -> object:
@@ -397,6 +486,25 @@ def test_agent_app_list_and_create_use_agent_route(
monkeypatch.setattr(
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
)
monkeypatch.setattr(
roster_controller.enterprise_rbac_service.RBACService.AgentPermissions,
"batch_get",
lambda _tenant_id, _account_id, agent_ids, **_kwargs: {
agent_id: [f"permission:{agent_id}"] for agent_id in agent_ids
},
)
replace_agent_whitelist = MagicMock()
initialize_agent_rbac_access = MagicMock()
monkeypatch.setattr(
roster_controller.enterprise_rbac_service.RBACService.AgentAccess,
"replace_whitelist",
replace_agent_whitelist,
)
monkeypatch.setattr(
roster_controller.initialize_created_app_rbac_access_task,
"delay",
initialize_agent_rbac_access,
)
def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str:
captured["get_or_create_debug_conversation"] = kwargs
@@ -427,6 +535,7 @@ def test_agent_app_list_and_create_use_agent_route(
assert listed["data"][0]["app_id"] == "app-list"
assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list"
assert listed["data"][0]["role"] == "List role"
assert listed["data"][0]["permission_keys"] == ["permission:agent-list"]
assert listed["data"][0]["active_config_is_published"] is False
assert listed["data"][0]["reference_count"] == 2
assert listed["data"][0]["published_reference_count"] == 1
@@ -468,12 +577,17 @@ def test_agent_app_list_and_create_use_agent_route(
assert created["app_id"] == "app-created"
assert created["debug_conversation_id"] == "debug-conversation-created"
assert created["role"] == "Created role"
assert created["permission_keys"] == ["permission:agent-created"]
assert "active_config_is_published" not in created
assert "bound_agent_id" not in created
create_call = cast(dict[str, object], captured["create"])
create_params = cast(Any, create_call["params"])
assert create_params.mode == "agent"
assert create_params.agent_role == "Coordinator"
replace_agent_whitelist.assert_called_once()
assert replace_agent_whitelist.call_args.args[:3] == ("tenant-1", account_id, "agent-created")
assert replace_agent_whitelist.call_args.args[3].automatic_include_workspace_members is True
initialize_agent_rbac_access.assert_called_once_with("tenant-1", account_id, agent_id="agent-created")
assert captured["get_or_create_debug_conversation"] == {
"tenant_id": "tenant-1",
"agent_id": "agent-created",
@@ -534,6 +648,7 @@ def test_agent_app_create_omits_optional_role_as_empty_string(
def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
) -> None:
monkeypatch.setattr(roster_controller.dify_config, "RBAC_ENABLED", True)
agent_id = "00000000-0000-0000-0000-000000000001"
tenant_id = "00000000-0000-0000-0000-000000000002"
app_id = "00000000-0000-0000-0000-000000000003"
@@ -573,6 +688,13 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
"agent_has_workflow_callable_active_snapshot",
lambda **_kwargs: False,
)
monkeypatch.setattr(
roster_controller.enterprise_rbac_service.RBACService.AgentPermissions,
"batch_get",
lambda _tenant_id, _account_id, agent_ids, **_kwargs: {
agent_id: [f"permission:{agent_id}"] for agent_id in agent_ids
},
)
class FakeAppService:
def get_app(self, app_obj: object, *, session: object) -> object:
@@ -597,6 +719,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
assert detail["debug_conversation_message_count"] == 2
assert detail["role"] == "Resolved role"
assert detail["access_ready"] is False
assert detail["permission_keys"] == [f"permission:{agent_id}"]
assert "active_config_is_published" not in detail
assert "bound_agent_id" not in detail
assert captured["get_app"] == {"app": app_model, "session": session}
@@ -642,12 +765,29 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
captured.update(kwargs)
return copied_app
def get_app_backing_agent(self, **kwargs: object) -> Agent:
captured["get_app_backing_agent"] = kwargs
return Agent(id="copied-agent", app_id="copied-app")
monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService())
monkeypatch.setattr(
roster_controller,
"_serialize_agent_app_detail",
lambda _session, app_model, **_kwargs: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name},
)
monkeypatch.setattr(roster_controller.dify_config, "RBAC_ENABLED", True)
replace_agent_whitelist = MagicMock()
initialize_agent_rbac_access = MagicMock()
monkeypatch.setattr(
roster_controller.enterprise_rbac_service.RBACService.AgentAccess,
"replace_whitelist",
replace_agent_whitelist,
)
monkeypatch.setattr(
roster_controller.initialize_created_app_rbac_access_task,
"delay",
initialize_agent_rbac_access,
)
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/copy",
json={
@@ -686,7 +826,12 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
"icon_type": "emoji",
"icon": "sparkles",
"icon_background": "#fff",
"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "copied-app"},
}
replace_agent_whitelist.assert_called_once()
assert replace_agent_whitelist.call_args.args[:3] == ("tenant-1", account_id, "copied-agent")
assert replace_agent_whitelist.call_args.args[3].automatic_include_workspace_members is True
initialize_agent_rbac_access.assert_called_once_with("tenant-1", account_id, agent_id="copied-agent")
def test_agent_debug_conversation_refresh_resets_build_for_current_user(

View File

@@ -628,6 +628,17 @@ class TestRbacPermissionRequired:
assert _extract_resource_id(RBACResourceScope.APP, "tenant-1") == "agent-1"
def test_extract_resource_id_supports_agent_scope(self):
app = Flask(__name__)
with app.test_request_context("/agent/agent-1"):
request.view_args = {"agent_id": "agent-1"}
assert _extract_resource_id(RBACResourceScope.AGENT, "tenant-1") == "agent-1"
with app.test_request_context("/agents/agent-2"):
request.view_args = {"resource_id": "agent-2"}
assert _extract_resource_id(RBACResourceScope.AGENT, "tenant-1") == "agent-2"
def test_legacy_admin_decorator_noops_when_rbac_enabled(self):
@is_admin_or_owner_required
def protected_view():

View File

@@ -389,6 +389,49 @@ class TestResourceAccessScopeBindings:
mock_sync_task.delay.assert_not_called()
def test_agent_whitelist_forwards_to_agent_access(self, app):
result = rbac_mod.svc.ResourceWhitelist(account_ids=["acct-2"], automatic_include_workspace_members=False)
with (
app.test_request_context("/workspaces/current/rbac/agents/agent-1/whitelist"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AgentAccess.whitelist",
return_value=result,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACAgentWhitelistApi.get)(
rbac_mod.RBACAgentWhitelistApi(),
"agent-1",
)
assert response == {"account_ids": ["acct-2"]}
mock_get.assert_called_once_with("tenant-1", "acct-1", "agent-1")
def test_agent_whitelist_put_forwards_to_agent_access(self, app):
result = rbac_mod.svc.ResourceWhitelist(account_ids=["acct-1"], automatic_include_workspace_members=True)
with (
app.test_request_context(
"/workspaces/current/rbac/agents/agent-1/whitelist",
method="PUT",
json={"automatic_include_workspace_members": True},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AgentAccess.replace_whitelist",
return_value=result,
) as mock_put,
):
response = inspect.unwrap(rbac_mod.RBACAgentWhitelistApi.put)(
rbac_mod.RBACAgentWhitelistApi(),
"agent-1",
)
assert response == {"account_ids": ["acct-1"]}
mock_put.assert_called_once()
args = mock_put.call_args.args
assert args[:3] == ("tenant-1", "acct-actor", "agent-1")
assert args[3].automatic_include_workspace_members is True
def test_app_whitelist_config_returns_switch_state_only(self, app):
result = rbac_mod.svc.ResourceWhitelistConfig(automatic_include_workspace_members=True)
with (
@@ -425,6 +468,24 @@ class TestResourceAccessScopeBindings:
assert response == {"automatic_include_workspace_members": False}
mock_get.assert_called_once_with("tenant-1", "acct-1", "dataset-1")
def test_agent_whitelist_config_returns_switch_state_only(self, app):
result = rbac_mod.svc.ResourceWhitelistConfig(automatic_include_workspace_members=True)
with (
app.test_request_context("/workspaces/current/rbac/agents/agent-1/whitelist_config"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AgentAccess.whitelist_config",
return_value=result,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACAgentWhitelistConfigApi.get)(
rbac_mod.RBACAgentWhitelistConfigApi(),
"agent-1",
)
assert response == {"automatic_include_workspace_members": True}
mock_get.assert_called_once_with("tenant-1", "acct-1", "agent-1")
def test_app_user_access_policy_assignment_forwards_ids(self, app):
with (
app.test_request_context(

View File

@@ -436,6 +436,23 @@ class TestResourceAccess:
assert call.json == {"access_policy_ids": ["policy-1"]}
assert out.access_policies[0].id == "policy-1"
def test_agent_replace_user_access_policies(self, mock_send: MagicMock):
mock_send.return_value = {
"access_policies": [{"id": "policy-1", "resource_type": "agent", "name": "Can preview"}]
}
payload = svc.ReplaceUserAccessPolicies(access_policy_ids=["policy-1"], account_ids=["acct-2"])
out = svc.RBACService.AgentAccess.replace_user_access_policies(
"tenant-1", "acct-actor", "agent-1", None, payload
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/agents/user-access-policies"
assert call.params == {"agent_id": "agent-1", "account_id": None}
assert call.json == {"access_policy_ids": ["policy-1"], "account_ids": ["acct-2"]}
assert out.access_policies[0].id == "policy-1"
def test_app_append_whitelist_members_batch(self, mock_send: MagicMock):
mock_send.return_value = None
@@ -480,6 +497,28 @@ class TestResourceAccess:
"data": [{"dataset_id": "dataset-1", "account_ids": ["acct-1", "acct-2"], "policy_id": "policy-1"}]
}
def test_agent_append_whitelist_members_batch(self, mock_send: MagicMock):
mock_send.return_value = None
svc.RBACService.AgentAccess.append_whitelist_members_batch(
"tenant-1",
"acct-actor",
[
svc.AppendAgentWhitelistMembersBatchItem(
agent_id="agent-1",
account_ids=["acct-1", "acct-2"],
policy_id="policy-1",
)
],
)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/agents/whitelist/members/batch"
assert call.json == {
"data": [{"agent_id": "agent-1", "account_ids": ["acct-1", "acct-2"], "policy_id": "policy-1"}]
}
def test_dataset_whitelist(self, mock_send: MagicMock):
mock_send.return_value = {"account_ids": ["acct-2"], "automatic_include_workspace_members": False}
@@ -491,6 +530,17 @@ class TestResourceAccess:
assert call.params == {"dataset_id": "dataset-1"}
assert out.account_ids == ["acct-2"]
def test_agent_whitelist(self, mock_send: MagicMock):
mock_send.return_value = {"account_ids": ["acct-2"], "automatic_include_workspace_members": False}
out = svc.RBACService.AgentAccess.whitelist("tenant-1", "acct-1", "agent-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/agents/whitelist"
assert call.params == {"agent_id": "agent-1"}
assert out.account_ids == ["acct-2"]
def test_app_whitelist_config(self, mock_send: MagicMock):
mock_send.return_value = {
"account_ids": ["acct-1"],
@@ -520,6 +570,38 @@ class TestResourceAccess:
assert call.params == {"dataset_id": "dataset-1"}
assert out.model_dump(mode="json") == {"automatic_include_workspace_members": False}
def test_agent_whitelist_config(self, mock_send: MagicMock):
mock_send.return_value = {
"account_ids": ["acct-1"],
"automatic_include_workspace_members": True,
"scope": "all",
}
out = svc.RBACService.AgentAccess.whitelist_config("tenant-1", "acct-1", "agent-1")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/agents/whitelist"
assert call.params == {"agent_id": "agent-1"}
assert out.model_dump(mode="json") == {"automatic_include_workspace_members": True}
def test_agent_replace_whitelist(self, mock_send: MagicMock):
mock_send.return_value = {"account_ids": ["acct-1"], "automatic_include_workspace_members": True}
out = svc.RBACService.AgentAccess.replace_whitelist(
"tenant-1",
"acct-1",
"agent-1",
svc.ReplaceMemberBindings(automatic_include_workspace_members=True),
)
call = _call_args(mock_send)
assert call.method == "PUT"
assert call.endpoint == "/rbac/agents/whitelist"
assert call.params == {"agent_id": "agent-1"}
assert call.json == {"automatic_include_workspace_members": True}
assert out.account_ids == ["acct-1"]
def test_dataset_legacy_whitelist_config_reads_old_scope_without_public_dump(self, mock_send: MagicMock):
mock_send.return_value = {
"account_ids": ["acct-1"],
@@ -748,37 +830,42 @@ class TestMyPermissions:
assert out.workspace.permission_keys == ["workspace.member.manage"]
@pytest.mark.parametrize(
("role", "workspace_keys", "app_keys", "dataset_keys"),
("role", "workspace_keys", "app_keys", "dataset_keys", "agent_keys"),
[
(
"owner",
svc._LEGACY_WORKSPACE_OWNER_KEYS,
svc._LEGACY_APP_OWNER_KEYS,
svc._LEGACY_DATASET_OWNER_KEYS,
svc._LEGACY_AGENT_OWNER_KEYS,
),
(
"admin",
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
svc._LEGACY_APP_ADMIN_KEYS,
svc._LEGACY_DATASET_ADMIN_KEYS,
svc._LEGACY_AGENT_ADMIN_KEYS,
),
(
"editor",
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
svc._LEGACY_APP_EDITOR_KEYS,
svc._LEGACY_DATASET_EDITOR_KEYS,
svc._LEGACY_AGENT_EDITOR_DEFAULT_KEYS,
),
(
"normal",
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
svc._LEGACY_APP_NORMAL_KEYS,
[],
svc._LEGACY_AGENT_NORMAL_KEYS,
),
(
"dataset_operator",
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
[],
svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
svc._LEGACY_AGENT_NORMAL_KEYS,
),
],
)
@@ -789,6 +876,7 @@ class TestMyPermissions:
workspace_keys: list[str],
app_keys: list[str],
dataset_keys: list[str],
agent_keys: list[str],
sqlite_session: Session,
config_overrides,
):
@@ -804,17 +892,21 @@ class TestMyPermissions:
assert len(out.workspace.permission_keys) == len(set(out.workspace.permission_keys))
assert out.app.default_permission_keys == app_keys
assert out.dataset.default_permission_keys == dataset_keys
assert out.agent.default_permission_keys == agent_keys
assert out.app.overrides == []
assert out.dataset.overrides == []
assert out.agent.overrides == []
if role == "owner":
assert "snippets.management" in out.workspace.permission_keys
assert "app.acl.preview" in out.workspace.permission_keys
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
assert "agent.acl.preview" in out.agent.default_permission_keys
assert not any(key.startswith("billing.") for key in out.workspace.permission_keys)
if role == "editor":
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
assert "agent.acl.log_manage" not in out.agent.default_permission_keys
assert "app.acl.deploy" not in out.app.default_permission_keys
@pytest.mark.parametrize(
@@ -924,12 +1016,16 @@ class TestMemberRoles:
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
*svc._LEGACY_APP_EDITOR_KEYS,
*svc._LEGACY_DATASET_EDITOR_KEYS,
*svc._LEGACY_AGENT_EDITOR_KEYS,
]
)
)
assert "snippets.create_and_modify" in out.roles[0].permission_keys
assert "app.acl.preview" in out.roles[0].permission_keys
assert "dataset.acl.preview" in out.roles[0].permission_keys
assert "agent.create" in out.roles[0].permission_keys
assert "agent.acl.preview" in out.roles[0].permission_keys
assert "agent.acl.log_manage" in out.roles[0].permission_keys
assert "app.acl.deploy" not in out.roles[0].permission_keys
def test_replace(self, mock_send: MagicMock, sqlite_session: Session):
@@ -1110,6 +1206,46 @@ class TestResourcePermissions:
"ds-2": svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
}
def test_agent_permissions_batch_get(self, mock_send: MagicMock, sqlite_session: Session):
mock_send.return_value = {
"data": [
{"resource_id": "agent-1", "permission_keys": ["agent.acl.view", "agent.acl.edit"]},
{"resource_id": "agent-2", "permission_keys": []},
]
}
out = svc.RBACService.AgentPermissions.batch_get(
"tenant-1", "acct-1", ["agent-1", "agent-2"], session=sqlite_session
)
call = _call_args(mock_send)
assert call.method == "POST"
assert call.endpoint == "/rbac/agents/permission-keys/batch"
assert call.json == {"agent_ids": ["agent-1", "agent-2"]}
assert out == {
"agent-1": ["agent.acl.view", "agent.acl.edit"],
"agent-2": [],
}
def test_agent_permissions_batch_get_uses_legacy_agent_acl_permissions_when_rbac_disabled(
self, mock_send: MagicMock, sqlite_session: Session, config_overrides
):
config_overrides(RBAC_ENABLED=False)
sqlite_session.add(
TenantAccountJoin(tenant_id="tenant-1", account_id="acct-1", role=svc.TenantAccountRole.EDITOR)
)
sqlite_session.commit()
out = svc.RBACService.AgentPermissions.batch_get(
"tenant-1", "acct-1", ["agent-1", "agent-2"], session=sqlite_session
)
mock_send.assert_not_called()
assert out == {
"agent-1": svc._LEGACY_AGENT_EDITOR_DEFAULT_KEYS,
"agent-2": svc._LEGACY_AGENT_EDITOR_DEFAULT_KEYS,
}
assert all("agent.acl.log_manage" not in permission_keys for permission_keys in out.values())
class TestListOption:
def test_empty_produces_empty_params(self):

View File

@@ -50,6 +50,36 @@ def test_initialize_created_app_rbac_access_task_batches_workspace_members(monke
assert call.kwargs["payload"].access_policy_ids == [task_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID]
def test_initialize_created_app_rbac_access_task_batches_agent_workspace_members(monkeypatch: pytest.MonkeyPatch):
import tasks.initialize_created_app_rbac_access_task as task_module
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
task_module.TenantService,
"iter_member_account_id_batches",
lambda tenant_id, batch_size, session: iter([["acct-1", "acct-2"], ["acct-3"]]),
)
replace_user_access_policies = MagicMock()
monkeypatch.setattr(
task_module.enterprise_rbac_service.RBACService.AgentAccess,
"replace_user_access_policies",
replace_user_access_policies,
)
initialize_created_app_rbac_access_task.run("tenant-1", "actor-1", agent_id="agent-1")
assert replace_user_access_policies.call_count == 2
assert replace_user_access_policies.call_args_list[0].kwargs["payload"].account_ids == ["acct-1", "acct-2"]
assert replace_user_access_policies.call_args_list[1].kwargs["payload"].account_ids == ["acct-3"]
for call in replace_user_access_policies.call_args_list:
assert call.kwargs["tenant_id"] == "tenant-1"
assert call.kwargs["account_id"] == "actor-1"
assert call.kwargs["agent_id"] == "agent-1"
assert call.kwargs["target_account_id"] is None
assert call.kwargs["payload"].access_policy_ids == [task_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID]
def test_initialize_created_app_rbac_access_task_retries_on_failure(monkeypatch: pytest.MonkeyPatch):
import tasks.initialize_created_app_rbac_access_task as task_module
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
@@ -86,6 +116,7 @@ def test_sync_joined_workspace_member_rbac_access_task_appends_auto_included_res
rbac.ResourceWhitelistConfigResource(resource_type=rbac.RBACResourceType.APP, resource_id="app-1"),
rbac.ResourceWhitelistConfigResource(resource_type=rbac.RBACResourceType.DATASET, resource_id="dataset-1"),
rbac.ResourceWhitelistConfigResource(resource_type=rbac.RBACResourceType.APP, resource_id="app-2"),
rbac.ResourceWhitelistConfigResource(resource_type=rbac.RBACResourceType.AGENT, resource_id="agent-1"),
]
configs = rbac.ResourceWhitelistConfigsResponse(
data=[
@@ -104,17 +135,24 @@ def test_sync_joined_workspace_member_rbac_access_task_appends_auto_included_res
resource_id="app-2",
automatic_include_workspace_members=False,
),
rbac.ResourceWhitelistConfigItem(
resource_type=rbac.RBACResourceType.AGENT,
resource_id="agent-1",
automatic_include_workspace_members=True,
),
]
)
batch_get = MagicMock(return_value=configs)
app_append = MagicMock()
dataset_append = MagicMock()
agent_append = MagicMock()
monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(task_module, "_iter_resource_config_batches", lambda tenant_id, batch_size: iter([resources]))
monkeypatch.setattr(rbac.RBACService.ResourceWhitelistConfigs, "batch_get", batch_get)
monkeypatch.setattr(rbac.RBACService.AppAccess, "append_whitelist_members_batch", app_append)
monkeypatch.setattr(rbac.RBACService.DatasetAccess, "append_whitelist_members_batch", dataset_append)
monkeypatch.setattr(rbac.RBACService.AgentAccess, "append_whitelist_members_batch", agent_append)
sync_joined_workspace_member_rbac_access_task.run("tenant-1", "member-1", "actor-1")
@@ -140,3 +178,12 @@ def test_sync_joined_workspace_member_rbac_access_task_appends_auto_included_res
assert dataset_call["data"][0].dataset_id == "dataset-1"
assert dataset_call["data"][0].account_ids == ["member-1"]
assert dataset_call["data"][0].policy_id == task_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID
agent_append.assert_called_once()
agent_call = agent_append.call_args.kwargs
assert agent_call["tenant_id"] == "tenant-1"
assert agent_call["account_id"] == "actor-1"
assert len(agent_call["data"]) == 1
assert agent_call["data"][0].agent_id == "agent-1"
assert agent_call["data"][0].account_ids == ["member-1"]
assert agent_call["data"][0].policy_id == task_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID