feat(rbac): agent RBAC/ACL scenes and resource-locator check bundles (#41768)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Xiyuan Chenandautofix-ci[bot] authored and GitHub committed 2026-09-07 04:39:43 +00:00
1 parent 60e983c493
commit 86134ea99e
152 files changed
+5360 -3405

No files matched your search

@@ -0,0 +1,278 @@
"""Unit tests for the agent-flavoured RBAC inner-API client.
`RBACService.AgentAccess`, the agent methods on `RBACService.WorkspaceAccess` and
`RBACService.Catalog.agent` are all built from one generic resource-access client.
These tests monkeypatch `_inner_call` and assert the HTTP method, the exact endpoint,
the query/body keys and the returned model for every operation.
"""
from __future__ import annotations
from collections.abc import Callable, Iterator
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from services.enterprise import rbac_service as svc
MODULE = "services.enterprise.rbac_service"
TENANT = "tenant-1"
ACTOR = "acct-1"
AGENT = "agent-1"
POLICY = "policy-1"
@pytest.fixture
def inner_call() -> Iterator[MagicMock]:
with patch(f"{MODULE}._inner_call") as call:
call.return_value = {"automatic_include_workspace_members": True}
yield call
def _last(call: MagicMock) -> SimpleNamespace:
call.assert_called_once()
args, kwargs = call.call_args
return SimpleNamespace(
method=args[0],
endpoint=args[1],
tenant_id=kwargs.get("tenant_id"),
account_id=kwargs.get("account_id"),
json=kwargs.get("json"),
params=kwargs.get("params"),
)
_AGENT_CASES: list[tuple[str, str, str, Callable[[], object]]] = [
(
"whitelist_resources",
"GET",
"/rbac/agents/whitelist/resources",
lambda: svc.RBACService.AgentAccess.whitelist_resources(TENANT, ACTOR),
),
(
"user_access_policies",
"GET",
"/rbac/agents/user-access-policies",
lambda: svc.RBACService.AgentAccess.user_access_policies(TENANT, ACTOR, agent_id=AGENT),
),
(
"replace_user_access_policies",
"PUT",
"/rbac/agents/user-access-policies",
lambda: svc.RBACService.AgentAccess.replace_user_access_policies(
TENANT,
ACTOR,
agent_id=AGENT,
target_account_id="member-1",
payload=svc.ReplaceUserAccessPolicies(access_policy_ids=[POLICY], account_ids=["member-1"]),
),
),
(
"whitelist",
"GET",
"/rbac/agents/whitelist",
lambda: svc.RBACService.AgentAccess.whitelist(TENANT, ACTOR, agent_id=AGENT),
),
(
"whitelist_config",
"GET",
"/rbac/agents/whitelist",
lambda: svc.RBACService.AgentAccess.whitelist_config(TENANT, ACTOR, agent_id=AGENT),
),
(
"legacy_whitelist_config",
"GET",
"/rbac/agents/whitelist",
lambda: svc.RBACService.AgentAccess.legacy_whitelist_config(TENANT, ACTOR, agent_id=AGENT),
),
(
"replace_whitelist",
"PUT",
"/rbac/agents/whitelist",
lambda: svc.RBACService.AgentAccess.replace_whitelist(
TENANT,
ACTOR,
agent_id=AGENT,
payload=svc.ReplaceMemberBindings(automatic_include_workspace_members=True),
),
),
(
"append_whitelist_members_batch",
"POST",
"/rbac/agents/whitelist/members/batch",
lambda: svc.RBACService.AgentAccess.append_whitelist_members_batch(
tenant_id=TENANT,
account_id=ACTOR,
data=[svc.AppendAgentWhitelistMembersBatchItem(agent_id=AGENT, account_ids=["member-1"], policy_id=POLICY)],
),
),
(
"matrix",
"GET",
"/rbac/agents/access-policy",
lambda: svc.RBACService.AgentAccess.matrix(TENANT, ACTOR, agent_id=AGENT),
),
(
"list_role_bindings",
"GET",
"/rbac/agents/access-policy/role-bindings",
lambda: svc.RBACService.AgentAccess.list_role_bindings(TENANT, ACTOR, agent_id=AGENT, policy_id=POLICY),
),
(
"replace_role_bindings",
"PUT",
"/rbac/agents/access-policy/role-bindings",
lambda: svc.RBACService.AgentAccess.replace_role_bindings(
TENANT, ACTOR, agent_id=AGENT, policy_id=POLICY, payload=svc.ReplaceRoleBindings(role_ids=["role-1"])
),
),
(
"list_member_bindings",
"GET",
"/rbac/agents/access-policy/member-bindings",
lambda: svc.RBACService.AgentAccess.list_member_bindings(TENANT, ACTOR, agent_id=AGENT, policy_id=POLICY),
),
(
"delete_member_bindings",
"DELETE",
"/rbac/agents/access-policy/member-bindings",
lambda: svc.RBACService.AgentAccess.delete_member_bindings(
TENANT, ACTOR, agent_id=AGENT, policy_id=POLICY, payload=svc.DeleteMemberBindings(account_ids=["member-1"])
),
),
(
"replace_bindings",
"PUT",
"/rbac/agents/access-policy/bindings",
lambda: svc.RBACService.AgentAccess.replace_bindings(
TENANT,
ACTOR,
agent_id=AGENT,
policy_id=POLICY,
payload=svc.ReplaceBindings(role_ids=["role-1"], account_ids=["member-1"]),
),
),
(
"workspace.agent_matrix",
"GET",
"/rbac/workspace/agents/access-policy",
lambda: svc.RBACService.WorkspaceAccess.agent_matrix(TENANT, ACTOR),
),
(
"workspace.list_agent_role_bindings",
"GET",
"/rbac/workspace/agents/access-policy/role-bindings",
lambda: svc.RBACService.WorkspaceAccess.list_agent_role_bindings(TENANT, ACTOR, POLICY),
),
(
"workspace.replace_agent_role_bindings",
"PUT",
"/rbac/workspace/agents/access-policy/role-bindings",
lambda: svc.RBACService.WorkspaceAccess.replace_agent_role_bindings(
TENANT, ACTOR, POLICY, svc.ReplaceRoleBindings(role_ids=["role-1"])
),
),
(
"workspace.list_agent_member_bindings",
"GET",
"/rbac/workspace/agents/access-policy/member-bindings",
lambda: svc.RBACService.WorkspaceAccess.list_agent_member_bindings(TENANT, ACTOR, POLICY),
),
(
"workspace.replace_agent_bindings",
"PUT",
"/rbac/workspace/agents/access-policy/bindings",
lambda: svc.RBACService.WorkspaceAccess.replace_agent_bindings(
TENANT, ACTOR, POLICY, svc.ReplaceBindings(role_ids=["role-1"], account_ids=["member-1"])
),
),
(
"catalog.agent",
"GET",
"/rbac/role-permissions/catalog/agent",
lambda: svc.RBACService.Catalog.agent(TENANT, account_id=ACTOR),
),
]
@pytest.mark.parametrize(
("method", "endpoint", "invoke"),
[(case[1], case[2], case[3]) for case in _AGENT_CASES],
ids=[case[0] for case in _AGENT_CASES],
)
def test_agent_operations_hit_the_agent_route(
inner_call: MagicMock, method: str, endpoint: str, invoke: Callable[[], object]
) -> None:
invoke()
call = _last(inner_call)
assert call.method == method
assert call.endpoint == endpoint
assert "/apps/" not in call.endpoint
assert "/datasets/" not in call.endpoint
assert call.tenant_id == TENANT
assert call.account_id == ACTOR
params = call.params or {}
assert "app_id" not in params
assert "dataset_id" not in params
if AGENT in params.values():
assert params.get("agent_id") == AGENT
_PARITY_CASES: list[tuple[str, Callable[[], object], Callable[[], object]]] = [
(
"whitelist_resources",
lambda: svc.RBACService.AppAccess.whitelist_resources(TENANT, ACTOR),
lambda: svc.RBACService.AgentAccess.whitelist_resources(TENANT, ACTOR),
),
(
"whitelist",
lambda: svc.RBACService.AppAccess.whitelist(TENANT, ACTOR, "res-1"),
lambda: svc.RBACService.AgentAccess.whitelist(TENANT, ACTOR, agent_id="res-1"),
),
(
"user_access_policies",
lambda: svc.RBACService.AppAccess.user_access_policies(TENANT, ACTOR, "res-1"),
lambda: svc.RBACService.AgentAccess.user_access_policies(TENANT, ACTOR, agent_id="res-1"),
),
(
"matrix",
lambda: svc.RBACService.AppAccess.matrix(TENANT, ACTOR, "res-1"),
lambda: svc.RBACService.AgentAccess.matrix(TENANT, ACTOR, agent_id="res-1"),
),
(
"list_role_bindings",
lambda: svc.RBACService.AppAccess.list_role_bindings(TENANT, ACTOR, "res-1", POLICY),
lambda: svc.RBACService.AgentAccess.list_role_bindings(TENANT, ACTOR, agent_id="res-1", policy_id=POLICY),
),
(
"list_member_bindings",
lambda: svc.RBACService.AppAccess.list_member_bindings(TENANT, ACTOR, "res-1", POLICY),
lambda: svc.RBACService.AgentAccess.list_member_bindings(TENANT, ACTOR, agent_id="res-1", policy_id=POLICY),
),
]
@pytest.mark.parametrize(
("app_call", "agent_call"),
[(case[1], case[2]) for case in _PARITY_CASES],
ids=[case[0] for case in _PARITY_CASES],
)
def test_same_path_and_param_shape_modulo_segment(
inner_call: MagicMock,
app_call: Callable[[], object],
agent_call: Callable[[], object],
) -> None:
app_call()
app = _last(inner_call)
inner_call.reset_mock()
agent_call()
agent = _last(inner_call)
assert app.endpoint.replace("/apps/", "/agents/") == agent.endpoint
assert app.method == agent.method
app_params = {("agent_id" if k == "app_id" else k): v for k, v in (app.params or {}).items()}
assert app_params == (agent.params or {})
@@ -748,37 +748,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_FULL_ACCESS_KEYS,
),
(
"admin",
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
svc._LEGACY_APP_ADMIN_KEYS,
svc._LEGACY_DATASET_ADMIN_KEYS,
svc._LEGACY_AGENT_FULL_ACCESS_KEYS,
),
(
"editor",
svc._LEGACY_WORKSPACE_EDITOR_KEYS,
svc._LEGACY_APP_EDITOR_KEYS,
svc._LEGACY_DATASET_EDITOR_KEYS,
svc._LEGACY_AGENT_FULL_ACCESS_KEYS,
),
(
"normal",
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
svc._LEGACY_APP_NORMAL_KEYS,
[],
svc._LEGACY_AGENT_PREVIEW_KEYS,
),
(
"dataset_operator",
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
svc._LEGACY_APP_DATASET_OPERATOR_KEYS,
svc._LEGACY_DATASET_DATASET_OPERATOR_KEYS,
svc._LEGACY_AGENT_PREVIEW_KEYS,
),
],
)
@@ -789,6 +794,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,8 +810,10 @@ 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
@@ -859,6 +867,7 @@ class TestMyPermissions:
assert out.workspace.permission_keys == []
assert out.app.default_permission_keys == []
assert out.dataset.default_permission_keys == []
assert out.agent.default_permission_keys == []
def test_get_with_single_resource_filters(self, mock_send: MagicMock, sqlite_session: Session):
mock_send.return_value = {
@@ -878,6 +887,27 @@ class TestMyPermissions:
assert call.params == {"app_id": "app-1"}
assert out.app.overrides[0].resource_id == "app-1"
def test_get_forwards_agent_id_and_parses_agent_snapshot(self, mock_send: MagicMock, sqlite_session: Session):
mock_send.return_value = {
"workspace": {"permission_keys": []},
"app": {"default_permission_keys": [], "overrides": []},
"dataset": {"default_permission_keys": [], "overrides": []},
"agent": {
"default_permission_keys": ["agent.acl.preview"],
"overrides": [{"resource_id": "agent-1", "permission_keys": ["agent.acl.edit"]}],
},
}
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1", agent_id="agent-1", session=sqlite_session)
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/my-permissions"
assert call.params == {"agent_id": "agent-1"}
assert out.agent.default_permission_keys == ["agent.acl.preview"]
assert out.agent.overrides[0].resource_id == "agent-1"
assert out.agent.overrides[0].permission_keys == ["agent.acl.edit"]
@pytest.mark.parametrize("sqlite_session", [(TenantAccountJoin,)], indirect=True)
class TestMemberRoles:
@@ -924,6 +954,7 @@ class TestMemberRoles:
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
*svc._LEGACY_APP_EDITOR_KEYS,
*svc._LEGACY_DATASET_EDITOR_KEYS,
*svc._LEGACY_AGENT_FULL_ACCESS_KEYS,
]
)
)
@@ -1126,9 +1157,8 @@ class TestListOption:
}
class TestLegacyAgentManageKey:
def test_legacy_agent_manage_key_membership(self):
# Preserve Agent access for every legacy role while external RBAC is disabled.
class TestLegacyAgentKeys:
def test_legacy_workspace_keys_no_longer_carry_agent_manage(self):
for keys in (
svc._LEGACY_WORKSPACE_OWNER_KEYS,
svc._LEGACY_WORKSPACE_ADMIN_KEYS,
@@ -1136,4 +1166,5 @@ class TestLegacyAgentManageKey:
svc._LEGACY_WORKSPACE_NORMAL_KEYS,
svc._LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS,
):
assert "agent.manage" in keys
assert "agent.manage" not in keys
assert {"agent.acl.preview", "agent.acl.access_point_view"} <= set(keys)
@@ -542,38 +542,25 @@ def test_export_dsl_loads_model_config_and_annotation_reply_with_request_session
load_annotation_reply_config.assert_called_once_with(service_session, app_id)
def test_ensure_agent_manage_permission_noops_when_rbac_disabled(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(RBAC_ENABLED=False)
check = Mock()
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
AppDslService._ensure_agent_manage_permission(_account())
check.assert_not_called()
def test_ensure_agent_manage_permission_allows_agent_manager(
monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
def test_agent_import_of_new_agent_checks_function_scope(
monkeypatch: pytest.MonkeyPatch,
unbound_session: Session,
config_overrides: Callable[..., None],
) -> None:
config_overrides(RBAC_ENABLED=True)
check = Mock(return_value=True)
monkeypatch.setattr("services.app_dsl_service.RBACService.CheckAccess.check", check)
account = _account()
AppDslService._ensure_agent_manage_permission(_account())
AppDslService(session=unbound_session)._ensure_agent_import_permission(account, app=None)
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, config_overrides: Callable[..., None]
) -> None:
config_overrides(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(_account())
check.assert_called_once_with(
account.current_tenant_id,
account.id,
scene=RBACPermission.AGENT_IMPORT_EXPORT_DSL,
resource_type=None,
resource_id=None,
)
def test_create_or_update_app_gates_agent_mode_before_creation(
@@ -27,6 +27,7 @@ from models.model import App, AppMode, AppModelConfig, IconType
from models.workflow import Workflow, WorkflowType
from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise import rbac_service as enterprise_rbac_service
def _persist_account(session: Session) -> Account:
@@ -222,6 +223,63 @@ class TestCreateAppTransactionBoundary:
)
class TestCreateAppRBACAccessInitialization:
"""`create_app` bootstraps RBAC access per app mode: agents get the agent flavour."""
@staticmethod
def _create(session: Session, account: Account, mode: AppMode) -> App:
with (
patch("services.app_service.app_was_created.send"),
patch(
"services.app_service.SystemFeatureService.is_webapp_auth_enabled",
return_value=False,
),
):
return AppService().create_app(
account.current_tenant_id,
CreateAppParams(name=f"RBAC {mode.value}", mode=mode.value),
account,
session=session,
)
def test_agent_app_initializes_agent_access_and_skips_the_app_creator_sync(
self, sqlite_session: Session, config_overrides: Callable[..., None]
) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, RBAC_ENABLED=True)
account = _persist_account(sqlite_session)
with (
patch(
"services.rbac_agent_access_service.enterprise_rbac_service.RBACService.AgentAccess.replace_whitelist"
) as replace_whitelist,
patch("services.rbac_agent_access_service.initialize_created_app_rbac_access_task.delay") as seed_task,
patch(
"services.rbac_agent_access_service.enterprise_rbac_service.RBACService.AccessPolicies"
".sync_creator_access_policy_member_bindings"
) as creator_sync,
patch(
"services.app_service.enterprise_rbac_service.try_sync_creator_access_policy_member_bindings"
) as app_creator_sync,
):
app = self._create(sqlite_session, account, AppMode.AGENT)
agent = sqlite_session.scalars(select(Agent).where(Agent.app_id == app.id)).one()
replace_whitelist.assert_called_once_with(
account.current_tenant_id,
account.id,
agent.id,
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
)
seed_task.assert_called_once_with(account.current_tenant_id, account.id, agent_id=agent.id)
creator_sync.assert_called_once_with(
account.current_tenant_id,
account.id,
resource_type=enterprise_rbac_service.RBACResourceType.AGENT,
resource_id=agent.id,
)
app_creator_sync.assert_not_called()
@pytest.mark.parametrize(
"update_status",
[AppService.update_app_site_status, AppService.update_app_api_status],
@@ -0,0 +1,56 @@
"""Unit tests for the RBAC bootstrap applied to a newly created agent."""
from __future__ import annotations
from collections.abc import Callable, Iterator
from unittest.mock import MagicMock, patch
import pytest
from services import rbac_agent_access_service
from services.enterprise import rbac_service as enterprise_rbac_service
MODULE = "services.rbac_agent_access_service"
TENANT = "tenant-1"
AGENT = "agent-1"
CREATOR = "account-1"
@pytest.fixture
def rbac_calls(config_overrides: Callable[..., None]) -> Iterator[MagicMock]:
config_overrides(RBAC_ENABLED=True)
recorder = MagicMock()
with (
patch(f"{MODULE}.enterprise_rbac_service.RBACService.AgentAccess.replace_whitelist") as replace_whitelist,
patch(f"{MODULE}.initialize_created_app_rbac_access_task.delay") as seed_task,
patch(
f"{MODULE}.enterprise_rbac_service.RBACService.AccessPolicies.sync_creator_access_policy_member_bindings"
) as creator_sync,
):
recorder.attach_mock(replace_whitelist, "replace_whitelist")
recorder.attach_mock(seed_task, "seed_task")
recorder.attach_mock(creator_sync, "creator_sync")
yield recorder
def test_initialize_agent_rbac_access_seeds_scope_members_and_creator_policy(
rbac_calls: MagicMock,
) -> None:
rbac_agent_access_service.initialize_agent_rbac_access(tenant_id=TENANT, agent_id=AGENT, creator_account_id=CREATOR)
rbac_calls.replace_whitelist.assert_called_once_with(
TENANT,
CREATOR,
AGENT,
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
)
rbac_calls.seed_task.assert_called_once_with(TENANT, CREATOR, agent_id=AGENT)
rbac_calls.creator_sync.assert_called_once_with(
TENANT,
CREATOR,
resource_type=enterprise_rbac_service.RBACResourceType.AGENT,
resource_id=AGENT,
)
assert [call[0] for call in rbac_calls.mock_calls] == ["seed_task", "creator_sync", "replace_whitelist"]