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,193 @@
import json
from collections.abc import Iterator
from contextlib import ExitStack
from dataclasses import dataclass, field
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner, Result
from commands.rbac import migrate_agent_permissions_to_rbac
from services.enterprise.rbac_service import (
LegacyAgentMigrationReport,
LegacyAgentRoleMigration,
_LegacyResourceWhitelistConfig,
)
MODULE = "commands.rbac"
def _events(output: str) -> list[dict[str, object]]:
return [json.loads(line) for line in output.splitlines() if line.startswith("{")]
@pytest.fixture(autouse=True)
def _no_agents() -> Iterator[None]:
with patch(f"{MODULE}._iter_agent_row_batches", return_value=iter(())):
yield
def _roles() -> list[LegacyAgentRoleMigration]:
return [
LegacyAgentRoleMigration(
role_id="r1",
role_name="ops",
added_keys=["agent.create"],
removed_keys=["agent.manage"],
bound_policies=["agent.full_access"],
),
LegacyAgentRoleMigration(role_id="r2", role_name="stuck", skipped="policy row missing"),
]
def test_apply_flag_writes_and_reports_applied() -> None:
with (
patch(f"{MODULE}._iter_tenant_ids", return_value=iter(["t1", "t2"])),
patch(
f"{MODULE}.RBACService.Migrations.migrate_agent_manage_roles",
return_value=LegacyAgentMigrationReport(roles=_roles()[:1]),
) as migrate,
):
result = CliRunner().invoke(migrate_agent_permissions_to_rbac, ["--apply"])
assert result.exit_code == 0, result.output
assert migrate.call_count == 2
assert all(call.kwargs == {"apply": True} for call in migrate.call_args_list)
events = _events(result.output)
assert [e["event"] for e in events] == ["agent_manage_role_migration_applied"] * 2
assert {e["tenant_id"] for e in events} == {"t1", "t2"}
assert "changed" in result.output
def _whitelist_config(
scope: str | None = "all",
account_ids: list[str] | None = None,
) -> _LegacyResourceWhitelistConfig:
return _LegacyResourceWhitelistConfig(rbac_whitelist_scope=scope, account_ids=account_ids or [])
@dataclass
class _AgentPhaseMocks:
agent_whitelist_config: MagicMock
app_whitelist_config: MagicMock
replace_whitelist: MagicMock
replace_user_access_policies: MagicMock
sync_creator_bindings: MagicMock
owner_account_id: MagicMock
member_batches: MagicMock
write_order: MagicMock
@dataclass
class _AgentPhaseSetup:
agents: list[tuple[str, str | None, str | None]]
configured_agent_ids: list[str] = field(default_factory=list)
agent_configs: list[_LegacyResourceWhitelistConfig] | None = None
app_config: _LegacyResourceWhitelistConfig = field(default_factory=_whitelist_config)
workspace_members: list[str] = field(default_factory=lambda: ["m1", "m2", "m3"])
owner_account_id: str = "owner-1"
def _run_agent_phase(args: list[str], setup: _AgentPhaseSetup) -> tuple[Result, _AgentPhaseMocks]:
def _member_batches(_tenant_id: str, batch_size: int) -> Iterator[list[str]]:
for start in range(0, len(setup.workspace_members), batch_size):
yield setup.workspace_members[start : start + batch_size]
with ExitStack() as stack:
stack.enter_context(patch(f"{MODULE}._iter_tenant_ids", return_value=iter(["t1"])))
stack.enter_context(
patch(
f"{MODULE}.RBACService.Migrations.migrate_agent_manage_roles",
return_value=LegacyAgentMigrationReport(),
)
)
stack.enter_context(patch(f"{MODULE}._iter_agent_row_batches", return_value=iter([setup.agents])))
stack.enter_context(
patch(
f"{MODULE}.RBACService.Migrations.list_configured_agent_ids",
return_value=setup.configured_agent_ids,
)
)
agent_whitelist_config = stack.enter_context(patch(f"{MODULE}.RBACService.AgentAccess.legacy_whitelist_config"))
if setup.agent_configs is None:
agent_whitelist_config.return_value = _whitelist_config()
else:
agent_whitelist_config.side_effect = setup.agent_configs
mocks = _AgentPhaseMocks(
agent_whitelist_config=agent_whitelist_config,
app_whitelist_config=stack.enter_context(
patch(f"{MODULE}.RBACService.AppAccess.legacy_whitelist_config", return_value=setup.app_config)
),
replace_whitelist=stack.enter_context(patch(f"{MODULE}.RBACService.AgentAccess.replace_whitelist")),
replace_user_access_policies=stack.enter_context(
patch(f"{MODULE}.RBACService.AgentAccess.replace_user_access_policies")
),
sync_creator_bindings=stack.enter_context(
patch(f"{MODULE}.RBACService.AccessPolicies.sync_creator_access_policy_member_bindings")
),
owner_account_id=stack.enter_context(
patch(f"{MODULE}._owner_account_id", return_value=setup.owner_account_id)
),
member_batches=stack.enter_context(
patch(f"{MODULE}._workspace_member_account_id_batches", side_effect=_member_batches)
),
write_order=MagicMock(),
)
mocks.write_order.attach_mock(mocks.replace_user_access_policies, "seed_members")
mocks.write_order.attach_mock(mocks.sync_creator_bindings, "sync_creator")
mocks.write_order.attach_mock(mocks.replace_whitelist, "replace_whitelist")
result = CliRunner().invoke(migrate_agent_permissions_to_rbac, args)
return result, mocks
def _write_order(mocks: _AgentPhaseMocks) -> list[str]:
return [name for name, _, _ in mocks.write_order.mock_calls]
def test_agent_bootstrap_apply_writes_whitelist_member_batches_and_creator_sync() -> None:
result, mocks = _run_agent_phase(
["--apply", "--member-batch-size", "2"],
_AgentPhaseSetup(agents=[("ag1", "c1", None)], workspace_members=["m1", "m2", "m3"]),
)
assert result.exit_code == 0, result.output
assert [e["event"] for e in _events(result.output)] == ["agent_access_bootstrap_applied"]
assert _events(result.output)[0]["dry_run"] is False
mocks.replace_whitelist.assert_called_once()
assert mocks.replace_whitelist.call_args.kwargs["agent_id"] == "ag1"
assert mocks.replace_whitelist.call_args.kwargs["account_id"] == "c1"
assert mocks.replace_whitelist.call_args.kwargs["payload"].automatic_include_workspace_members is True
mocks.member_batches.assert_called_once_with("t1", 2)
assert mocks.replace_user_access_policies.call_count == 2
calls = mocks.replace_user_access_policies.call_args_list
assert [call.kwargs["payload"].account_ids for call in calls] == [["m1", "m2"], ["m3"]]
assert all(call.kwargs["payload"].access_policy_ids == ["default"] for call in calls)
assert all(call.kwargs["target_account_id"] is None for call in calls)
mocks.sync_creator_bindings.assert_called_once()
assert mocks.sync_creator_bindings.call_args.kwargs["resource_id"] == "ag1"
assert mocks.sync_creator_bindings.call_args.kwargs["account_id"] == "c1"
assert "1 agent(s) changed, 0 already initialised" in result.output
assert _write_order(mocks) == ["seed_members", "seed_members", "sync_creator", "replace_whitelist"]
def test_agent_bootstrap_is_idempotent_on_a_second_apply() -> None:
result, mocks = _run_agent_phase(
["--apply"],
_AgentPhaseSetup(
agents=[("ag1", "c1", None), ("ag2", "c2", None)],
configured_agent_ids=["ag1", "ag2"],
),
)
assert result.exit_code == 0, result.output
events = _events(result.output)
assert [e["event"] for e in events] == ["agent_access_bootstrap_skipped"] * 2
assert {e["reason"] for e in events} == {"already_initialized"}
mocks.replace_whitelist.assert_not_called()
mocks.sync_creator_bindings.assert_not_called()
mocks.replace_user_access_policies.assert_not_called()
assert "0 agent(s) changed, 2 already initialised" in result.output
@@ -1,6 +1,6 @@
from __future__ import annotations
from inspect import getclosurevars, unwrap
from inspect import unwrap
from types import FunctionType, SimpleNamespace
import pytest
@@ -11,6 +11,7 @@ from controllers.console import agent_app_sandbox as module
from models.account import Account
from models.model import App, AppMode, IconType
from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
class _AgentAppService:
@@ -155,12 +156,10 @@ def _account() -> Account:
],
)
def test_sandbox_resources_require_app_view_layout(method: FunctionType) -> None:
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
config = getclosurevars(rbac_wrapper).nonlocals
[check] = rbac_checks(method)
assert config["resource_type"] == module.RBACResourceScope.APP
assert config["scene"] == module.RBACPermission.APP_VIEW_LAYOUT
assert config["resource_required"] is True
assert isinstance(check.locator, module.PlainApp)
assert check.scene == module.RBACPermission.APP_VIEW_LAYOUT
def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
@@ -1,186 +0,0 @@
from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session, scoped_session, sessionmaker
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 import Account
from models.agent import Agent, AgentScope, AgentSource, AgentStatus
from models.model import App, AppMode
from tests.unit_tests.config_override import config_overrides_context
TENANT_ID = "tenant-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 _persist_app(
session: Session,
*,
scope: AgentScope | None = None,
status: AgentStatus = AgentStatus.ACTIVE,
) -> App:
app_model = App(
id="app-1",
tenant_id=TENANT_ID,
name="Managed App",
mode=AppMode.AGENT if scope is not None else AppMode.CHAT,
enable_site=True,
enable_api=False,
)
session.add(app_model)
if scope is not None:
agent = Agent(
tenant_id=TENANT_ID,
name=f"{scope.value} agent",
scope=scope,
source=AgentSource.AGENT_APP if scope == AgentScope.ROSTER else AgentSource.WORKFLOW,
status=status,
app_id=app_model.id if scope == AgentScope.ROSTER else None,
backing_app_id=app_model.id if scope == AgentScope.WORKFLOW_ONLY else None,
)
session.add(agent)
session.commit()
return app_model
def _patch_guard(account: Account, rbac_enabled: bool):
return (
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(account, TENANT_ID)),
config_overrides_context(RBAC_ENABLED=rbac_enabled),
)
class TestAgentManageRequiredForAgentApp:
@pytest.fixture(autouse=True)
def _bind_database(
self,
sqlite_session: Session,
sqlite_session_factory: sessionmaker[Session],
):
self.sqlite_session = sqlite_session
self.account = Account(name="Guard User", email="guard@example.com")
self.account.id = "account-1"
session_proxy = scoped_session(sqlite_session_factory)
with patch("controllers.console.app.wraps.db.session", session_proxy):
yield
session_proxy.remove()
def test_non_agent_app_passes_through_without_workspace_check(self):
view, calls = _guarded_view()
_persist_app(self.sqlite_session)
patches = _patch_guard(self.account, rbac_enabled=True)
with patches[0], patches[1], 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()
_persist_app(self.sqlite_session, scope=AgentScope.ROSTER)
patches = _patch_guard(self.account, rbac_enabled=True)
with patches[0], patches[1], 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=self.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()
_persist_app(self.sqlite_session, scope=AgentScope.ROSTER)
patches = _patch_guard(self.account, rbac_enabled=True)
with (
patches[0],
patches[1],
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()
_persist_app(self.sqlite_session, scope=AgentScope.ROSTER)
patches = _patch_guard(self.account, rbac_enabled=False)
with patches[0], patches[1], 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()
_persist_app(self.sqlite_session, scope=AgentScope.WORKFLOW_ONLY)
patches = _patch_guard(self.account, rbac_enabled=False)
with patches[0], patches[1]:
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()
_persist_app(self.sqlite_session, scope=AgentScope.WORKFLOW_ONLY)
patches = _patch_guard(self.account, rbac_enabled=True)
with patches[0], patches[1], 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()
_persist_app(self.sqlite_session, scope=AgentScope.ROSTER, status=AgentStatus.ARCHIVED)
patches = _patch_guard(self.account, rbac_enabled=True)
with patches[0], patches[1], patch("controllers.console.app.wraps.enforce_rbac_access") as gate:
view(app_id="app-1")
gate.assert_called_once()
def test_resource_id_path_alias_is_resolved(self):
view, _ = _guarded_view()
_persist_app(self.sqlite_session, scope=AgentScope.ROSTER)
patches = _patch_guard(self.account, rbac_enabled=True)
with patches[0], patches[1], 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(self.account, rbac_enabled=True)
with patches[0], patches[1], 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"}]
@@ -111,11 +111,11 @@ def test_agent_console_audio_api_uses_agent_draft(
calls["asr"] = kwargs
return {"text": "agent transcript"}
def enforce_rbac_access(**kwargs):
def enforce_rbac_checks(**kwargs):
calls["rbac"] = kwargs
monkeypatch.setattr(audio_module, "resolve_agent_runtime_app_model", resolve_agent_runtime_app_model)
monkeypatch.setattr(audio_module, "enforce_rbac_access", enforce_rbac_access)
monkeypatch.setattr(audio_module, "enforce_rbac_checks", enforce_rbac_checks)
monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug)
monkeypatch.setattr(AudioService, "transcript_agent_asr", transcript_agent_asr)
@@ -138,13 +138,13 @@ def test_agent_console_audio_api_uses_agent_draft(
assert response == {"text": "agent transcript"}
assert calls["resolver"] == {"session": session, "tenant_id": "tenant-1", "agent_id": agent_id}
assert calls["rbac"] == {
"tenant_id": "tenant-1",
"account_id": "account-1",
"resource_type": audio_module.RBACResourceScope.APP,
"scene": audio_module.RBACPermission.APP_TEST_AND_RUN,
"path_args": {"app_id": "backing-app-1"},
}
rbac_call = calls["rbac"]
assert rbac_call["tenant_id"] == "tenant-1"
assert rbac_call["account_id"] == "account-1"
assert rbac_call["path_args"] == {"app_id": "backing-app-1"}
(rbac_check,) = rbac_call["checks"]
assert rbac_check.scene is audio_module.RBACPermission.APP_TEST_AND_RUN
assert isinstance(rbac_check.locator, audio_module.PlainApp)
assert calls["draft"] == {
"tenant_id": "tenant-1",
"agent_id": str(agent_id),
@@ -216,7 +216,7 @@ def test_agent_console_audio_api_checks_rbac_with_backing_app_id(
soul_loaded = True
return AgentSoulConfig()
monkeypatch.setattr(audio_module, "enforce_rbac_access", deny_access)
monkeypatch.setattr(audio_module, "enforce_rbac_checks", deny_access)
monkeypatch.setattr(AgentComposerService, "load_agent_soul_for_debug", load_agent_soul_for_debug)
api = AgentChatMessageAudioApi()
@@ -315,19 +315,20 @@ class TestAppMCPServerRefreshController:
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-1"),
),
patch("controllers.common.rbac.locators.agent_binding", return_value=None),
patch("controllers.common.rbac.locators.PlainApp.owner_id", return_value=None),
patch(
"controllers.common.wraps.enforce_rbac_access",
"controllers.common.rbac.checks.RBACService.CheckAccess.check",
side_effect=PermissionCheckedError,
) as enforce_rbac_access,
) as check_access,
pytest.raises(PermissionCheckedError),
):
method(AppMCPServerRefreshController(), app_id="app-1")
enforce_rbac_access.assert_called_once_with(
tenant_id="tenant-1",
account_id="account-1",
resource_type=RBACResourceScope.APP,
check_access.assert_called_once_with(
"tenant-1",
"account-1",
scene=RBACPermission.APP_EDIT,
resource_required=True,
path_args={"app_id": "app-1"},
resource_type=RBACResourceScope.APP,
resource_id="app-1",
)
@@ -11,6 +11,7 @@ import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden
from controllers.common.rbac import PlainApp
from controllers.console import flask_admission
from controllers.console.app import ops_trace as ops_trace_module
from controllers.console.app.error import (
@@ -138,19 +139,19 @@ def test_trace_config_mutations_require_app_tracing_permission_when_rbac_is_enab
lambda: AccountWithTenant(account=account, tenant_id=WORKSPACE_ID),
)
denied = MagicMock(side_effect=Forbidden())
monkeypatch.setattr(flask_admission, "enforce_rbac_access", denied)
monkeypatch.setattr(flask_admission, "enforce_rbac_checks", denied)
with app.test_request_context(), pytest.raises(Forbidden):
_admission_injector(method)(None, app_id=UUID(APP_ID))
denied.assert_called_once_with(
tenant_id=WORKSPACE_ID,
account_id=ACCOUNT_ID,
resource_type=ops_trace_module.RBACResourceScope.APP,
scene=ops_trace_module.RBACPermission.APP_TRACING_CONFIG,
resource_required=True,
path_args={"app_id": UUID(APP_ID)},
)
denied.assert_called_once()
call = denied.call_args.kwargs
assert call["tenant_id"] == WORKSPACE_ID
assert call["account_id"] == ACCOUNT_ID
assert call["path_args"] == {"app_id": UUID(APP_ID)}
(check,) = call["checks"]
assert check.scene == ops_trace_module.RBACPermission.APP_TRACING_CONFIG
assert isinstance(check.locator, PlainApp)
def test_trace_config_get_preserves_read_access_for_normal_member() -> None:
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Iterator
from inspect import getclosurevars, unwrap
from inspect import unwrap
from unittest.mock import ANY, PropertyMock, patch
import pytest
@@ -28,6 +28,7 @@ from services.entities.knowledge_entities.rag_pipeline_entities import PipelineT
from services.errors.account import NoPermissionError
from services.errors.rag_pipeline import RagPipelineResourceNotFoundError
from tests.unit_tests.config_override import config_overrides_context
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
def _template_item() -> dict[str, object]:
@@ -362,11 +363,8 @@ class TestCustomizedPipelineTemplateApi:
class TestPublishCustomizedPipelineTemplateApi:
def test_post_uses_pipeline_release_rbac_scene(self) -> None:
method = PublishCustomizedPipelineTemplateApi.post
while "scene" not in getclosurevars(method).nonlocals:
method = method.__wrapped__
assert getclosurevars(method).nonlocals["scene"] == module.RBACPermission.DATASET_PIPELINE_RELEASE
[check] = rbac_checks(PublishCustomizedPipelineTemplateApi.post)
assert check.scene == module.RBACPermission.DATASET_PIPELINE_RELEASE
def test_post_validates_payload_and_returns_empty_204(self) -> None:
api = PublishCustomizedPipelineTemplateApi()
@@ -1,5 +1,5 @@
from collections.abc import Callable, Iterator
from inspect import getclosurevars, unwrap
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -9,6 +9,7 @@ from sqlalchemy import Engine
from sqlalchemy.orm import Session, scoped_session, sessionmaker
from controllers.common.errors import InvalidArgumentError, NotFoundError
from controllers.common.rbac import RBACResourceScope
from controllers.console import console_ns
from controllers.console.app.error import DraftWorkflowNotExist
from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable import (
@@ -21,7 +22,7 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_draft_variable impor
RagPipelineVariableResetApi,
WorkflowDraftVariablePatchPayload,
)
from controllers.console.wraps import RBACPermission, RBACResourceScope
from controllers.console.wraps import RBACPermission
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from core.workflow.variable_prefixes import SYSTEM_VARIABLE_NODE_ID
from factories.variable_factory import build_segment
@@ -30,6 +31,7 @@ from graphon.variables import IntegerVariable, StringVariable
from models.account import Account, TenantAccountRole
from models.dataset import Pipeline
from models.workflow import Workflow, WorkflowDraftVariable, WorkflowType
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
@pytest.fixture
@@ -102,12 +104,11 @@ def restx_config(app):
def test_rag_draft_variable_routes_require_dataset_edit_permission() -> None:
route = RagPipelineVariableApi.get
legacy_gate = unwrap(route, stop=lambda decorator: "edit_permission_required" in decorator.__code__.co_qualname)
rbac_gate = unwrap(route, stop=lambda decorator: "scene" in getclosurevars(decorator).nonlocals)
assert "edit_permission_required" in legacy_gate.__code__.co_qualname
permissions = getclosurevars(rbac_gate).nonlocals
assert permissions["resource_type"] == RBACResourceScope.DATASET
assert permissions["scene"] == RBACPermission.DATASET_EDIT
[check] = rbac_checks(route)
assert check.locator.scope == RBACResourceScope.DATASET
assert check.scene == RBACPermission.DATASET_EDIT
class TestRagPipelineVariableCollectionApi:
@@ -166,7 +166,7 @@ class TestTagListApi:
app.test_request_context("/"),
config_overrides_context(RBAC_ENABLED=True),
patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")),
patch.object(module, "enforce_rbac_access") as enforce_rbac_access,
patch.object(module, "enforce_rbac_checks") as enforce_rbac_checks,
):
unwrap(TagListApi().post)(
TagListApi(),
@@ -174,13 +174,13 @@ class TestTagListApi:
request_context,
)
enforce_rbac_access.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
enforce_rbac_checks.assert_called_once()
rbac_kwargs = enforce_rbac_checks.call_args.kwargs
assert rbac_kwargs["tenant_id"] == "tenant-1"
assert rbac_kwargs["account_id"] == "user-1"
(rbac_check,) = rbac_kwargs["checks"]
assert rbac_check.scene is module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY
assert isinstance(rbac_check.locator, module.Workspace)
def test_post_rejects_read_only_member(self, app: Flask, request_context: RequestContext) -> None:
readonly = _account(TenantAccountRole.NORMAL)
@@ -249,7 +249,7 @@ class TestTagUpdateDeleteApi:
app.test_request_context("/"),
config_overrides_context(RBAC_ENABLED=True),
patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")),
patch.object(module, "enforce_rbac_access") as enforce_rbac_access,
patch.object(module, "enforce_rbac_checks") as enforce_rbac_checks,
):
result, status = unwrap(TagUpdateDeleteApi().patch)(
TagUpdateDeleteApi(),
@@ -258,13 +258,13 @@ class TestTagUpdateDeleteApi:
"tag-1",
)
enforce_rbac_access.assert_called_once_with(
tenant_id="tenant-1",
account_id="user-1",
resource_type=module.RBACResourceScope.WORKSPACE,
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
resource_required=False,
)
enforce_rbac_checks.assert_called_once()
rbac_kwargs = enforce_rbac_checks.call_args.kwargs
assert rbac_kwargs["tenant_id"] == "tenant-1"
assert rbac_kwargs["account_id"] == "user-1"
(rbac_check,) = rbac_kwargs["checks"]
assert rbac_check.scene is module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY
assert isinstance(rbac_check.locator, module.Workspace)
tags_service.update_tag.assert_called_once_with(request_context, "tag-1", UpdateTagInput(name="Updated"))
assert status == 200
assert result["binding_count"] == "3"
@@ -345,12 +345,12 @@ class TestTagUpdateDeleteApi:
app.test_request_context("/"),
config_overrides_context(RBAC_ENABLED=True),
patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")),
patch.object(module, "enforce_rbac_access") as enforce_rbac_access,
patch.object(module, "enforce_rbac_checks") as enforce_rbac_checks,
):
unwrap(TagUpdateDeleteApi().delete)(TagUpdateDeleteApi(), request_context, "tag-1")
tags_service.get_tag_type.assert_called_once_with(request_context, "tag-1")
enforce_rbac_access.assert_called_once()
enforce_rbac_checks.assert_called_once()
def test_delete_does_not_authorize_tag_outside_current_workspace(
self, app: Flask, request_context: RequestContext, tags_service: MagicMock
@@ -363,12 +363,12 @@ class TestTagUpdateDeleteApi:
app.test_request_context("/"),
config_overrides_context(RBAC_ENABLED=True),
patch.object(module, "current_account_with_tenant", return_value=(owner, "tenant-1")),
patch.object(module, "enforce_rbac_access") as enforce_rbac_access,
patch.object(module, "enforce_rbac_checks") as enforce_rbac_checks,
):
with pytest.raises(NotFound):
unwrap(TagUpdateDeleteApi().delete)(TagUpdateDeleteApi(), request_context, "tag-1")
enforce_rbac_access.assert_not_called()
enforce_rbac_checks.assert_not_called()
class TestTagBindings:
@@ -57,9 +57,9 @@ def _make_account(role: TenantAccountRole) -> Account:
return account
def _persist_app(session: Session, *, mode: AppMode = AppMode.CHAT) -> App:
def _persist_app(session: Session, *, mode: AppMode = AppMode.CHAT, app_id: str = "app-1") -> App:
app = App(
id="app-1",
id=app_id,
tenant_id="tenant-1",
name="API key app",
mode=mode,
@@ -260,15 +260,23 @@ def test_api_key_lists_require_matching_rbac_permission(config_overrides: Callab
cases = [
(
lambda: AppApiKeyListResource().get(resource_id=api_id),
[(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True)],
{
"scene": RBACPermission.APP_RELEASE_AND_VERSION,
"resource_type": RBACResourceScope.APP,
"resource_id": str(api_id),
},
),
(
lambda: DatasetApiKeyApi().get(),
[(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, False)],
{"scene": RBACPermission.DATASET_API_KEY_MANAGE, "resource_type": None, "resource_id": None},
),
(
lambda: DatasetApiKeyListResource().get(resource_id=api_id),
[(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, True)],
{
"scene": RBACPermission.DATASET_API_KEY_MANAGE,
"resource_type": RBACResourceScope.DATASET,
"resource_id": str(api_id),
},
),
]
@@ -276,20 +284,25 @@ def test_api_key_lists_require_matching_rbac_permission(config_overrides: Callab
app.test_request_context("/"),
patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
patch("controllers.common.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
patch("controllers.common.rbac.locators.agent_binding", return_value=None),
patch("controllers.common.rbac.locators.PlainApp.owner_id", return_value=None),
patch("controllers.common.rbac.locators.DatasetId.owner_id", return_value=None),
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
):
for invoke, expected_gates in cases:
for invoke, expected_kwargs in cases:
with patch(
"controllers.common.wraps.enforce_rbac_access",
side_effect=[None] * (len(expected_gates) - 1) + [Forbidden()],
) as enforce_rbac_access:
"controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=False
) as check_access:
with pytest.raises(Forbidden):
invoke()
assert [
(kwargs["resource_type"], kwargs["scene"], kwargs["resource_required"])
for _, kwargs in enforce_rbac_access.call_args_list
] == expected_gates
check_access.assert_called_once_with(
"tenant-1",
account.id,
scene=expected_kwargs["scene"],
resource_type=expected_kwargs["resource_type"],
resource_id=expected_kwargs["resource_id"],
)
get_api_key_list.assert_not_called()
@@ -1,28 +1,27 @@
from inspect import getclosurevars, unwrap
from inspect import unwrap
from types import FunctionType
import pytest
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.common.rbac import RBACPermission, Workspace
from controllers.console.datasets.rag_pipeline.datasource_auth import DatasourceAuthOauthCustomClient
from controllers.console.workspace.tool_providers import ToolOAuthCustomClient
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
@pytest.mark.parametrize(
("method", "legacy_gate", "resource_type"),
("method", "legacy_gate"),
[
(ToolOAuthCustomClient.delete, "is_admin_or_owner_required", RBACResourceScope.WORKSPACE),
(DatasourceAuthOauthCustomClient.delete, "edit_permission_required", RBACResourceScope.DATASET),
(ToolOAuthCustomClient.delete, "is_admin_or_owner_required"),
(DatasourceAuthOauthCustomClient.delete, "edit_permission_required"),
],
)
def test_custom_oauth_client_delete_requires_management_permission(
method: FunctionType, legacy_gate: str, resource_type: RBACResourceScope
) -> None:
def test_custom_oauth_client_delete_requires_management_permission(method: FunctionType, legacy_gate: str) -> None:
legacy_wrapper = unwrap(method, stop=lambda wrapper: legacy_gate in wrapper.__code__.co_qualname)
assert legacy_gate in legacy_wrapper.__code__.co_qualname
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == resource_type
assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
assert rbac_config["resource_required"] is False
# Both routes pass resource_required=False, so their bundle resolves to a Workspace
# locator regardless of the resource_type they declared (see _single_check).
[check] = rbac_checks(method)
assert check.scene == RBACPermission.CREDENTIAL_MANAGE
assert isinstance(check.locator, Workspace)
@@ -1,9 +1,9 @@
from inspect import getclosurevars, unwrap
from inspect import unwrap
from types import FunctionType
import pytest
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.common.rbac import AgentId, RBACPermission, Workspace
from controllers.console.agent.composer import AgentComposerApi
from controllers.console.agent.roster import AgentAppApi
from controllers.console.datasets.data_source import DataSourceApi
@@ -11,6 +11,7 @@ from controllers.console.datasets.rag_pipeline.datasource_auth import Datasource
from controllers.console.workspace.model_providers import ModelProviderCredentialApi
from controllers.console.workspace.models import ModelProviderModelCredentialApi
from controllers.console.workspace.tool_providers import ToolBuiltinProviderAddApi, ToolOAuthCustomClient
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
@pytest.mark.parametrize(
@@ -27,11 +28,9 @@ def test_workspace_credential_mutations_require_management_permission(
legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == permission
assert rbac_config["resource_required"] is False
[check] = rbac_checks(method)
assert check.scene == permission
assert isinstance(check.locator, Workspace)
@pytest.mark.parametrize(
@@ -49,11 +48,9 @@ def test_model_provider_credential_get_requires_admin_and_rbac(
legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
assert rbac_config["resource_required"] is False
[check] = rbac_checks(method)
assert check.scene == RBACPermission.CREDENTIAL_MANAGE
assert isinstance(check.locator, Workspace)
def test_tool_oauth_custom_client_get_requires_admin_and_rbac() -> None:
@@ -64,11 +61,9 @@ def test_tool_oauth_custom_client_get_requires_admin_and_rbac() -> None:
legacy_wrapper = unwrap(method, stop=lambda wrapper: "is_admin_or_owner_required" in wrapper.__code__.co_qualname)
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
assert rbac_config["resource_required"] is False
[check] = rbac_checks(method)
assert check.scene == RBACPermission.CREDENTIAL_MANAGE
assert isinstance(check.locator, Workspace)
def test_datasource_auth_get_requires_edit_and_rbac() -> None:
@@ -79,11 +74,9 @@ def test_datasource_auth_get_requires_edit_and_rbac() -> None:
edit_wrapper = unwrap(method, stop=lambda wrapper: "edit_permission_required" in wrapper.__code__.co_qualname)
assert "edit_permission_required" in edit_wrapper.__code__.co_qualname
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.DATASET
assert rbac_config["scene"] == RBACPermission.CREDENTIAL_MANAGE
assert rbac_config["resource_required"] is False
[check] = rbac_checks(method)
assert check.scene == RBACPermission.CREDENTIAL_MANAGE
assert isinstance(check.locator, Workspace)
@pytest.mark.parametrize(
@@ -94,10 +87,8 @@ def test_datasource_auth_get_requires_edit_and_rbac() -> None:
],
)
def test_agent_app_get_requires_rbac(method: FunctionType) -> None:
"""GET endpoints that return agent app details or composer state must enforce
the same RBAC gates as their sibling PUT/DELETE methods."""
rbac_wrapper = unwrap(method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname)
rbac_config = getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == RBACPermission.AGENT_MANAGE
assert rbac_config["resource_required"] is False
"""GET endpoints that return agent app details or composer state must require
the agent preview scene on the agent resource, same as their sibling routes."""
[check] = rbac_checks(method)
assert check.scene == RBACPermission.AGENT_PREVIEW
assert isinstance(check.locator, AgentId)
@@ -4,14 +4,14 @@ from typing import override
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, request
from flask import Flask
from flask_login import LoginManager, UserMixin
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import HTTPException
from controllers.common.wraps import _extract_resource_id
from controllers.common.rbac import DatasetId, PlainApp, RBACCheck, Workspace
from controllers.console import api as console_api
from controllers.console import flask_admission
from controllers.console import wraps as wraps_module
@@ -41,10 +41,10 @@ from controllers.console.wraps import (
from enums import DeploymentEdition
from libs.login import AccountWithTenant
from machinery.context import RequestContext
from machinery.errors import ActiveWorkspaceRequiredError, AdmissionConfigurationError
from machinery.errors import ActiveWorkspaceRequiredError
from models import Account, DifySetup
from models.account import AccountStatus, TenantAccountRole
from models.dataset import Dataset, RateLimitLog
from models.dataset import RateLimitLog
from services.entities.feature_entities import LicenseStatus
from tests.unit_tests.config_override import config_overrides_context
@@ -359,15 +359,13 @@ class TestCurrentContextInjection:
),
patch("controllers.console.flask_admission.get_request_id", return_value="request-1"),
patch("controllers.console.flask_admission.get_trace_id", return_value=None),
patch("controllers.console.flask_admission.enforce_rbac_access") as enforce_rbac_access,
patch("controllers.console.flask_admission.enforce_rbac_checks") as enforce_rbac_checks,
):
class Handler:
@flask_admission.console_account_admission(
allowed_roles=frozenset({TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
rbac_resource_scope=RBACResourceScope.WORKSPACE,
rbac_permission=RBACPermission.CREDENTIAL_CREATE,
rbac_resource_required=False,
rbac_checks=[RBACCheck(RBACPermission.CREDENTIAL_CREATE, Workspace())],
)
def post(self, request_context: RequestContext):
return request_context
@@ -378,18 +376,14 @@ class TestCurrentContextInjection:
assert isinstance(result, RequestContext)
assert result.active_workspace_id == "tenant-123"
assert result.trace_id == "trace-1"
enforce_rbac_access.assert_called_once_with(
tenant_id="tenant-123",
account_id=current_user.id,
resource_type=RBACResourceScope.WORKSPACE,
scene=RBACPermission.CREDENTIAL_CREATE,
resource_required=False,
path_args={},
)
def test_console_account_admission_rejects_incomplete_rbac_requirement(self):
with pytest.raises(AdmissionConfigurationError, match="configured together"):
flask_admission.console_account_admission(rbac_resource_scope=RBACResourceScope.WORKSPACE)
enforce_rbac_checks.assert_called_once()
call_kwargs = enforce_rbac_checks.call_args.kwargs
assert call_kwargs["tenant_id"] == "tenant-123"
assert call_kwargs["account_id"] == current_user.id
assert call_kwargs["path_args"] == {}
(check,) = call_kwargs["checks"]
assert check.scene is RBACPermission.CREDENTIAL_CREATE
assert isinstance(check.locator, Workspace)
def test_console_account_admission_can_admit_uninitialized_accounts(self):
current_user = make_account()
@@ -501,67 +495,72 @@ class TestRbacPermissionRequired:
def test_resource_scoped_check_uses_resource_id(self):
current_user = make_account("account-1")
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE)
@rbac_permission_required(RBACCheck(RBACPermission.APP_DELETE, PlainApp()))
def protected_view(**kwargs):
return "ok"
with (
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-1")),
patch("controllers.common.wraps._extract_resource_id", return_value="app-123") as mock_extract,
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned,
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check,
Flask(__name__).test_request_context("/"),
patch(
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-1"),
),
patch("controllers.common.rbac.locators.agent_binding", return_value=None) as mock_binding,
patch("controllers.common.rbac.locators.PlainApp.owner_id", return_value=None) as mock_owner,
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=True) as mock_check,
):
assert protected_view(app_id="app-123") == "ok"
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_binding.assert_called_once_with("tenant-1", "app-123")
mock_owner.assert_called_once()
mock_check.assert_called_once_with(
"tenant-1",
"account-1",
scene="app_delete",
resource_type="app",
scene=RBACPermission.APP_DELETE,
resource_type=RBACResourceScope.APP,
resource_id="app-123",
)
def test_workspace_scoped_check_skips_resource_id_extraction(self):
current_user = make_account("account-2")
@rbac_permission_required(
RBACResourceScope.DATASET, RBACPermission.DATASET_CREATE_AND_MANAGEMENT, resource_required=False
)
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_CREATE_AND_MANAGEMENT, Workspace()))
def protected_view():
return "ok"
with (
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-2")),
patch("controllers.common.wraps._extract_resource_id") as mock_extract,
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=False) as mock_owned,
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check,
Flask(__name__).test_request_context("/"),
patch(
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-2"),
),
patch("controllers.common.rbac.locators.DatasetId.owner_id") as mock_owner,
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=True) as mock_check,
):
assert protected_view() == "ok"
mock_extract.assert_not_called()
mock_owned.assert_not_called()
mock_owner.assert_not_called()
mock_check.assert_called_once_with(
"tenant-2",
"account-2",
scene="dataset_create_and_management",
resource_type="dataset",
scene=RBACPermission.DATASET_CREATE_AND_MANAGEMENT,
resource_type=None,
resource_id=None,
)
def test_workspace_scene_omits_resource_type(self):
current_user = make_account("account-3")
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
@rbac_permission_required(RBACCheck(RBACPermission.WORKSPACE_ROLE_MANAGE, Workspace()))
def protected_view():
return "ok"
with (
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-3")),
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=True) as mock_check,
patch(
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-3"),
),
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=True) as mock_check,
):
assert protected_view() == "ok"
@@ -576,137 +575,46 @@ class TestRbacPermissionRequired:
def test_resource_owned_app_skips_rbac_check(self):
current_user = make_account("account-4")
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_DELETE)
@rbac_permission_required(RBACCheck(RBACPermission.APP_DELETE, PlainApp()))
def protected_view(**kwargs):
return "ok"
with (
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-4")),
patch("controllers.common.wraps._extract_resource_id", return_value="app-123"),
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned,
patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check,
Flask(__name__).test_request_context("/"),
patch(
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-4"),
),
patch("controllers.common.rbac.locators.agent_binding", return_value=None),
patch("controllers.common.rbac.locators.PlainApp.owner_id", return_value="account-4") as mock_owner,
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check") as mock_check,
):
assert protected_view(app_id="app-123") == "ok"
mock_owned.assert_called_once_with("tenant-4", "account-4", "app", "app-123")
mock_owner.assert_called_once()
mock_check.assert_not_called()
def test_resource_owned_dataset_skips_rbac_check(self):
current_user = make_account("account-5")
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_EDIT)
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_EDIT, DatasetId()))
def protected_view(**kwargs):
return "ok"
with (
patch("controllers.common.wraps.current_account_with_tenant", return_value=(current_user, "tenant-5")),
patch("controllers.common.wraps._extract_resource_id", return_value="dataset-123"),
patch("controllers.common.wraps._is_resource_owned_by_current_user", return_value=True) as mock_owned,
patch("controllers.common.wraps.RBACService.CheckAccess.check") as mock_check,
Flask(__name__).test_request_context("/"),
patch(
"controllers.common.wraps.current_account_with_tenant",
return_value=(current_user, "tenant-5"),
),
patch("controllers.common.rbac.locators.DatasetId.owner_id", return_value="account-5") as mock_owner,
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check") as mock_check,
):
assert protected_view(dataset_id="dataset-123") == "ok"
mock_owned.assert_called_once_with("tenant-5", "account-5", "dataset", "dataset-123")
mock_owner.assert_called_once()
mock_check.assert_not_called()
def test_extract_resource_id_prefers_path_args(self):
app = Flask(__name__)
with app.test_request_context("/"):
request.view_args = {"app_id": "view-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__)
with app.test_request_context("/"):
request.view_args = {"app_id": "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, "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, "tenant-1") == "dataset-1"
def test_extract_resource_id_scopes_pipeline_resolution_to_the_calling_tenant(self, sqlite_session: Session):
app = Flask(__name__)
pipeline_id = "00000000-0000-0000-0000-000000000001"
current_tenant_id = "00000000-0000-0000-0000-000000000002"
foreign_dataset = Dataset(
id="00000000-0000-0000-0000-000000000003",
tenant_id="00000000-0000-0000-0000-000000000004",
name="Foreign decoy",
created_by="00000000-0000-0000-0000-000000000005",
pipeline_id=pipeline_id,
)
current_dataset = Dataset(
id="00000000-0000-0000-0000-000000000006",
tenant_id=current_tenant_id,
name="Current tenant dataset",
created_by="00000000-0000-0000-0000-000000000007",
pipeline_id=pipeline_id,
)
sqlite_session.add_all([foreign_dataset, current_dataset])
unscoped_dataset = sqlite_session.scalar(select(Dataset).where(Dataset.pipeline_id == pipeline_id))
assert unscoped_dataset is foreign_dataset
with (
app.test_request_context("/rag/pipelines/pipeline-1"),
patch("controllers.common.wraps.db", SimpleNamespace(session=sqlite_session)),
):
request.view_args = {"pipeline_id": pipeline_id}
assert _extract_resource_id(RBACResourceScope.DATASET, current_tenant_id) == current_dataset.id
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
def protected_view():
@@ -6,6 +6,7 @@ from unittest.mock import patch
import pytest
from flask import Flask
from controllers.common.rbac import Workspace
from controllers.console import console_ns
from controllers.console.workspace.endpoint import (
DeprecatedEndpointCreateApi,
@@ -24,10 +25,11 @@ from controllers.console.workspace.endpoint import (
EndpointUpdatePayload,
LegacyEndpointUpdatePayload,
)
from controllers.console.wraps import RBACPermission, RBACResourceScope
from controllers.console.wraps import RBACPermission
from core.entities.provider_entities import ProviderConfig, ProviderConfigType
from core.plugin.entities.endpoint import EndpointEntityWithInstance, EndpointProviderDeclaration
from core.plugin.impl.exc import PluginPermissionDeniedError
from tests.unit_tests.controllers.rbac_introspection import rbac_checks
def _endpoint_entity() -> EndpointEntityWithInstance:
@@ -65,13 +67,9 @@ def test_endpoint_lists_require_management_permission(method: FunctionType) -> N
)
assert "is_admin_or_owner_required" in legacy_wrapper.__code__.co_qualname
rbac_wrapper = inspect.unwrap(
method, stop=lambda wrapper: "rbac_permission_required" in wrapper.__code__.co_qualname
)
rbac_config = inspect.getclosurevars(rbac_wrapper).nonlocals
assert rbac_config["resource_type"] == RBACResourceScope.WORKSPACE
assert rbac_config["scene"] == RBACPermission.PLUGIN_MODEL_CONFIG
assert rbac_config["resource_required"] is False
[check] = rbac_checks(method)
assert check.scene == RBACPermission.PLUGIN_MODEL_CONFIG
assert isinstance(check.locator, Workspace)
class TestEndpointCollectionApi:
@@ -85,6 +85,25 @@ class TestMyPermissions:
assert response["app"]["default_permission_keys"] == ["app.acl.deploy"]
mock_get.assert_called_once()
def test_forwards_agent_id_query_param(self, app):
permissions = rbac_mod.svc.MyPermissionsResponse(
agent=rbac_mod.svc.ResourcePermissionSnapshot(
default_permission_keys=["agent.acl.preview"],
)
)
with (
app.test_request_context("/workspaces/current/rbac/my-permissions?agent_id=agent-1"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.MyPermissions.get",
return_value=permissions,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACMyPermissionsApi.get)(rbac_mod.RBACMyPermissionsApi())
assert mock_get.call_args.kwargs["agent_id"] == "agent-1"
assert response["agent"]["default_permission_keys"] == ["agent.acl.preview"]
class TestAccessMatrixAccountNames:
def test_hydrates_missing_account_names(self):
@@ -332,172 +351,6 @@ class TestPaginationMapping:
assert options.reverse is True
class TestResourceAccessScopeBindings:
def test_app_whitelist_all_schedules_member_policy_sync(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/apps/app-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.AppAccess.replace_whitelist",
return_value=rbac_mod.svc.ResourceWhitelist(automatic_include_workspace_members=True),
),
patch("controllers.console.workspace.rbac.initialize_created_app_rbac_access_task") as mock_sync_task,
):
inspect.unwrap(rbac_mod.RBACAppWhitelistApi.put)(rbac_mod.RBACAppWhitelistApi(), "app-1")
mock_sync_task.delay.assert_called_once_with("tenant-1", "acct-actor", app_id="app-1")
def test_dataset_whitelist_all_schedules_member_policy_sync(self, app):
# Widening a dataset to the whole workspace only records the scope; without granting the
# default policy to the current members nobody actually gains access.
with (
app.test_request_context(
"/workspaces/current/rbac/datasets/dataset-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.DatasetAccess.replace_whitelist",
return_value=rbac_mod.svc.ResourceWhitelist(automatic_include_workspace_members=True),
),
patch("controllers.console.workspace.rbac.initialize_created_app_rbac_access_task") as mock_sync_task,
):
inspect.unwrap(rbac_mod.RBACDatasetWhitelistApi.put)(rbac_mod.RBACDatasetWhitelistApi(), "dataset-1")
mock_sync_task.delay.assert_called_once_with("tenant-1", "acct-actor", dataset_id="dataset-1")
def test_dataset_whitelist_specific_does_not_schedule_member_policy_sync(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/datasets/dataset-1/whitelist",
method="PUT",
json={"automatic_include_workspace_members": False},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.replace_whitelist",
return_value=rbac_mod.svc.ResourceWhitelist(automatic_include_workspace_members=False),
),
patch("controllers.console.workspace.rbac.initialize_created_app_rbac_access_task") as mock_sync_task,
):
inspect.unwrap(rbac_mod.RBACDatasetWhitelistApi.put)(rbac_mod.RBACDatasetWhitelistApi(), "dataset-1")
mock_sync_task.delay.assert_not_called()
def test_app_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/apps/app-1/whitelist_config"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AppAccess.whitelist_config",
return_value=result,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACAppWhitelistConfigApi.get)(
rbac_mod.RBACAppWhitelistConfigApi(),
"app-1",
)
assert response == {"automatic_include_workspace_members": True}
mock_get.assert_called_once_with("tenant-1", "acct-1", "app-1")
def test_dataset_whitelist_config_returns_switch_state_only(self, app):
result = rbac_mod.svc.ResourceWhitelistConfig(automatic_include_workspace_members=False)
with (
app.test_request_context("/workspaces/current/rbac/datasets/dataset-1/whitelist_config"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.whitelist_config",
return_value=result,
) as mock_get,
):
response = inspect.unwrap(rbac_mod.RBACDatasetWhitelistConfigApi.get)(
rbac_mod.RBACDatasetWhitelistConfigApi(),
"dataset-1",
)
assert response == {"automatic_include_workspace_members": False}
mock_get.assert_called_once_with("tenant-1", "acct-1", "dataset-1")
def test_app_user_access_policy_assignment_forwards_ids(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/apps/app-1/users/acct-target/access-policies",
method="PUT",
json={"access_policy_ids": ["policy-1", "policy-2"]},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AppAccess.replace_user_access_policies"
) as mock_replace,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACAppUserAccessPolicyAssignmentApi.put)(
rbac_mod.RBACAppUserAccessPolicyAssignmentApi(),
"app-1",
"acct-target",
)
tenant_id, actor_id, app_id, target_id, payload = mock_replace.call_args.args
assert (tenant_id, actor_id, app_id, target_id) == (
"tenant-1",
"acct-actor",
"app-1",
"acct-target",
)
assert payload.access_policy_ids == ["policy-1", "policy-2"]
def test_app_member_bindings_delete_forwards_account_ids(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/apps/app-1/access-policies/policy-1/member-bindings",
method="DELETE",
json={"account_ids": ["acct-2", "acct-3"]},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch("controllers.console.workspace.rbac.svc.RBACService.AppAccess.delete_member_bindings") as mock_delete,
):
response = inspect.unwrap(rbac_mod.RBACAppMemberBindingsApi.delete)(
rbac_mod.RBACAppMemberBindingsApi(),
"app-1",
"policy-1",
)
assert response == {"result": "success"}
tenant_id, actor_id, app_id, policy_id, payload = mock_delete.call_args.args
assert (tenant_id, actor_id, app_id, policy_id) == ("tenant-1", "acct-actor", "app-1", "policy-1")
assert payload.account_ids == ["acct-2", "acct-3"]
def test_dataset_member_bindings_delete_forwards_account_ids(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/datasets/dataset-1/access-policies/policy-1/member-bindings",
method="DELETE",
json={"account_ids": ["acct-2"]},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.delete_member_bindings"
) as mock_delete,
):
response = inspect.unwrap(rbac_mod.RBACDatasetMemberBindingsApi.delete)(
rbac_mod.RBACDatasetMemberBindingsApi(),
"dataset-1",
"policy-1",
)
assert response == {"result": "success"}
tenant_id, actor_id, dataset_id, policy_id, payload = mock_delete.call_args.args
assert (tenant_id, actor_id, dataset_id, policy_id) == ("tenant-1", "acct-actor", "dataset-1", "policy-1")
assert payload.account_ids == ["acct-2"]
class TestPaginationForwarding:
def test_access_policies_get_forwards_outer_pagination_params(self, app):
with (
@@ -517,88 +370,6 @@ class TestPaginationForwarding:
assert options.results_per_page == 25
assert options.reverse is False
def test_workspace_app_matrix_forwards_outer_pagination_params(self, app):
with (
app.test_request_context("/workspaces/current/rbac/workspace/apps/access-policy?page=4&limit=10"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.app_matrix") as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACWorkspaceAppMatrixApi.get)(rbac_mod.RBACWorkspaceAppMatrixApi())
_, kwargs = mock_list.call_args
options = kwargs["options"]
assert options.page_number == 4
assert options.results_per_page == 10
assert options.reverse is None
def test_workspace_dataset_matrix_forwards_outer_pagination_params(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/workspace/datasets/access-policy?page=5&limit=15&reverse=true"
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.WorkspaceAccess.dataset_matrix") as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACWorkspaceDatasetMatrixApi.get)(rbac_mod.RBACWorkspaceDatasetMatrixApi())
_, kwargs = mock_list.call_args
options = kwargs["options"]
assert options.page_number == 5
assert options.results_per_page == 15
assert options.reverse is True
def test_app_user_access_policies_forwards_outer_pagination_params(self, app):
result = rbac_mod.svc.ResourceUserAccessPoliciesResponse()
with (
app.test_request_context(
"/workspaces/current/rbac/apps/app-1/user-access-policies?page=2&limit=30&reverse=false"
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AppAccess.user_access_policies",
return_value=result,
) as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACAppUserAccessPoliciesApi.get)(
rbac_mod.RBACAppUserAccessPoliciesApi(),
"app-1",
)
args, kwargs = mock_list.call_args
assert args == ("tenant-1", "acct-1", "app-1")
options = kwargs["options"]
assert options.page_number == 2
assert options.results_per_page == 30
assert options.reverse is False
def test_dataset_user_access_policies_forwards_outer_pagination_params(self, app):
result = rbac_mod.svc.ResourceUserAccessPoliciesResponse()
with (
app.test_request_context(
"/workspaces/current/rbac/datasets/dataset-1/user-access-policies?page=3&limit=40&reverse=true"
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch(
"controllers.console.workspace.rbac.svc.RBACService.DatasetAccess.user_access_policies",
return_value=result,
) as mock_list,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACDatasetUserAccessPoliciesApi.get)(
rbac_mod.RBACDatasetUserAccessPoliciesApi(),
"dataset-1",
)
args, kwargs = mock_list.call_args
assert args == ("tenant-1", "acct-1", "dataset-1")
options = kwargs["options"]
assert options.page_number == 3
assert options.results_per_page == 40
assert options.reverse is True
class TestAccessPolicyBindingLockUnlock:
def test_lock_forwards_binding_id(self, app):
@@ -653,7 +424,7 @@ class TestWorkspaceRbacGuards:
"controllers.common.wraps.current_account_with_tenant",
return_value=(_account(), "tenant-1"),
),
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False),
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=False),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.create") as mock_create,
):
with pytest.raises(Forbidden):
@@ -672,7 +443,7 @@ class TestWorkspaceRbacGuards:
"controllers.common.wraps.current_account_with_tenant",
return_value=(_account(), "tenant-1"),
),
patch("controllers.common.wraps.RBACService.CheckAccess.check", return_value=False),
patch("controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=False),
patch("controllers.console.workspace.rbac.svc.RBACService.AccessPolicies.create") as mock_create,
):
with pytest.raises(Forbidden):
@@ -0,0 +1,167 @@
"""Contract tests for the generated app / dataset / agent access-permission routes.
``controllers.console.workspace.rbac`` builds the same twelve endpoints for every
resource kind from ``_RESOURCE_ACCESS_ROUTES``. These tests pin the two things the
generation must not get wrong: the registered URLs (with their class names and HTTP
methods) and which inner-API client each handler reaches for.
"""
from __future__ import annotations
import inspect
from collections.abc import Callable, Generator
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from controllers.console import console_ns
from controllers.console.workspace import rbac as rbac_mod
from enums import DeploymentEdition
from services.enterprise import rbac_service as svc
RESOURCE_ID = {
svc.RBACResourceType.APP: "app-1",
svc.RBACResourceType.DATASET: "dataset-1",
svc.RBACResourceType.AGENT: "agent-1",
}
# The URL segment and path parameter of each resource kind, spelled out here instead of read
# back from the same enum the controller builds its URLs from, so a rename fails this test.
RESOURCE_URL_PARTS = {
svc.RBACResourceType.APP: ("apps", "app_id"),
svc.RBACResourceType.DATASET: ("datasets", "dataset_id"),
svc.RBACResourceType.AGENT: ("agents", "agent_id"),
}
def _segment(spec: rbac_mod._ResourceAccessRoutes) -> str:
return RESOURCE_URL_PARTS[spec.resource_type][0]
def _id_param(spec: rbac_mod._ResourceAccessRoutes) -> str:
return RESOURCE_URL_PARTS[spec.resource_type][1]
@pytest.fixture
def app() -> Flask:
flask_app = Flask(__name__)
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture(autouse=True)
def _rbac_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE,
RBAC_ENABLED=True,
LOGIN_DISABLED=True,
)
@pytest.fixture(params=rbac_mod._RESOURCE_ACCESS_ROUTES, ids=lambda spec: spec.resource_type.value)
def spec(request: pytest.FixtureRequest) -> rbac_mod._ResourceAccessRoutes:
return request.param
@pytest.fixture
def apis(spec: rbac_mod._ResourceAccessRoutes) -> rbac_mod._ResourceAccessApis:
return rbac_mod._RESOURCE_ACCESS_APIS[spec.resource_type]
@pytest.fixture
def resource_id(spec: rbac_mod._ResourceAccessRoutes) -> str:
return RESOURCE_ID[spec.resource_type]
@pytest.fixture(autouse=True)
def _patched_current_ids() -> Generator[None]:
with patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")):
yield
def _expected_routes(spec: rbac_mod._ResourceAccessRoutes) -> dict[str, tuple[str, set[str]]]:
resource = f"/workspaces/current/rbac/{_segment(spec)}/<uuid:{_id_param(spec)}>"
workspace = f"/workspaces/current/rbac/workspace/{_segment(spec)}"
prefix = spec.class_prefix
return {
f"/workspaces/current/rbac/role-permissions/catalog/{spec.resource_type.value}": (
f"RBAC{prefix}CatalogApi",
{"GET"},
),
f"{resource}/access-policy": (f"RBAC{prefix}MatrixApi", {"GET"}),
f"{resource}/whitelist": (f"RBAC{prefix}WhitelistApi", {"GET", "PUT"}),
f"{resource}/whitelist_config": (f"RBAC{prefix}WhitelistConfigApi", {"GET"}),
f"{resource}/user-access-policies": (f"RBAC{prefix}UserAccessPoliciesApi", {"GET"}),
f"{resource}/users/<uuid:target_account_id>/access-policies": (
f"RBAC{prefix}UserAccessPolicyAssignmentApi",
{"PUT"},
),
f"{resource}/access-policies/<uuid:policy_id>/role-bindings": (f"RBAC{prefix}RoleBindingsApi", {"GET"}),
f"{resource}/access-policies/<string:policy_id>/member-bindings": (
f"RBAC{prefix}MemberBindingsApi",
{"GET", "DELETE"},
),
f"{workspace}/access-policy": (f"RBACWorkspace{prefix}MatrixApi", {"GET"}),
f"{workspace}/access-policies/<uuid:policy_id>/role-bindings": (
f"RBACWorkspace{prefix}RoleBindingsApi",
{"GET"},
),
f"{workspace}/access-policies/<uuid:policy_id>/bindings": (f"RBACWorkspace{prefix}BindingsApi", {"PUT"}),
f"{workspace}/access-policies/<uuid:policy_id>/member-bindings": (
f"RBACWorkspace{prefix}MemberBindingsApi",
{"GET"},
),
}
def _registered_resource_by_url() -> dict[str, type]:
registered: dict[str, type] = {}
for route in console_ns.resources:
for url in route.urls:
registered[url] = route.resource
return registered
def test_every_resource_kind_registers_the_same_twelve_routes(spec: rbac_mod._ResourceAccessRoutes) -> None:
registered = _registered_resource_by_url()
for url, (class_name, methods) in _expected_routes(spec).items():
assert url in registered, f"{url} is not registered"
resource = registered[url]
assert resource.__name__ == class_name
assert set(resource.methods or ()) == methods
@pytest.mark.parametrize("automatic_include_workspace_members", [True, False])
def test_whitelist_put_queues_the_seed_task_only_when_auto_including(
app: Flask,
apis: rbac_mod._ResourceAccessApis,
spec: rbac_mod._ResourceAccessRoutes,
resource_id: str,
automatic_include_workspace_members: bool,
) -> None:
replace = MagicMock(
return_value=svc.ResourceWhitelist(
automatic_include_workspace_members=automatic_include_workspace_members,
)
)
with (
app.test_request_context(
f"/{_segment(spec)}/{resource_id}/whitelist",
method="PUT",
json={"automatic_include_workspace_members": automatic_include_workspace_members},
),
patch.object(spec.access, "replace_whitelist", replace),
patch("controllers.console.workspace.rbac.initialize_created_app_rbac_access_task") as seed_task,
):
inspect.unwrap(vars(apis.whitelist)["put"])(apis.whitelist(), **{_id_param(spec): resource_id})
tenant_id, actor_id, target_id, payload = replace.call_args.args
assert (tenant_id, actor_id, target_id) == ("tenant-1", "acct-actor", resource_id)
assert payload.automatic_include_workspace_members is automatic_include_workspace_members
if automatic_include_workspace_members:
seed_task.delay.assert_called_once_with("tenant-1", "acct-actor", **{_id_param(spec): resource_id})
else:
seed_task.delay.assert_not_called()
@@ -1,7 +1,8 @@
import uuid
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.openapi.auth.composition import account_pipeline, auth_router, external_sso_pipeline
from controllers.openapi.auth.data import RBACRequirement, RequestContext
from controllers.openapi.auth.data import RequestContext
from controllers.openapi.auth.flow import When
from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter
from controllers.openapi.auth.verify import (
@@ -12,7 +13,7 @@ from controllers.openapi.auth.verify import (
check_workspace_mismatch,
check_workspace_role,
)
from core.rbac import RBACPermission, RBACResourceScope
from core.rbac import RBACPermission
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models.account import TenantAccountRole
@@ -206,7 +207,7 @@ def _selected_auth_steps_with_rbac(rbac):
def test_account_pipeline_selects_rbac_step_when_required():
rbac = RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_VIEW_LAYOUT)
rbac = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())
assert check_rbac_permission in _selected_auth_steps_with_rbac(rbac)
@@ -1,5 +1,6 @@
from unittest.mock import patch
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.openapi.auth.conditions import (
EDITION_CLOUD,
EDITION_COMMUNITY,
@@ -18,8 +19,8 @@ from controllers.openapi.auth.conditions import (
data_cond,
request_cond,
)
from controllers.openapi.auth.data import AuthData, RBACRequirement, RequestContext
from core.rbac import RBACPermission, RBACResourceScope
from controllers.openapi.auth.data import AuthData, RequestContext
from core.rbac import RBACPermission
from enums import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models.account import TenantAccountRole
@@ -153,7 +154,7 @@ def test_webapp_run_scoped_false_when_scope_none():
def _rbac_req():
return RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_TEST_AND_RUN)
return RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())
def test_has_rbac_true():
@@ -6,7 +6,8 @@ import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden, NotFound
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.openapi.auth.data import AuthData
from controllers.openapi.auth.verify import (
check_acl,
check_app_access,
@@ -18,7 +19,7 @@ from controllers.openapi.auth.verify import (
check_workspace_mismatch,
check_workspace_role,
)
from core.rbac import RBACPermission, RBACResourceScope
from core.rbac import RBACPermission
from libs.oauth_bearer import Scope, TokenType
from models.account import Tenant, TenantAccountRole
from models.model import App
@@ -85,25 +86,25 @@ def test_check_app_access_raises_when_not_member():
# --- check_rbac_permission ---
_RBAC_REQ = RBACRequirement(resource_type=RBACResourceScope.APP, scene=RBACPermission.APP_VIEW_LAYOUT)
_RBAC_REQ = RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())
def test_check_rbac_noop_when_no_requirement():
with patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce:
with patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce:
check_rbac_permission(_data(rbac=None, caller_kind="account"))
mock_enforce.assert_not_called()
def test_check_rbac_noop_when_rbac_disabled(config_overrides: Callable[..., None]):
config_overrides(RBAC_ENABLED=False)
with patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce:
with patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce:
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="account"))
mock_enforce.assert_not_called()
def test_check_rbac_skips_end_user_caller():
with (
patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce,
patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce,
):
check_rbac_permission(_data(rbac=_RBAC_REQ, caller_kind="end_user"))
mock_enforce.assert_not_called()
@@ -126,17 +127,18 @@ def test_check_rbac_enforces_for_account_caller():
path_params={"app_id": "app-1"},
)
with (
patch("controllers.openapi.auth.verify.enforce_rbac_access") as mock_enforce,
patch("controllers.openapi.auth.verify.enforce_rbac_checks") as mock_enforce,
):
check_rbac_permission(data)
mock_enforce.assert_called_once_with(
tenant_id="t1",
account_id=str(account_id),
resource_type=RBACResourceScope.APP,
scene=RBACPermission.APP_VIEW_LAYOUT,
resource_required=True,
path_args={"app_id": "app-1"},
)
mock_enforce.assert_called_once()
call_kwargs = mock_enforce.call_args.kwargs
assert call_kwargs["tenant_id"] == "t1"
assert call_kwargs["account_id"] == str(account_id)
assert call_kwargs["path_args"] == {"app_id": "app-1"}
(check,) = call_kwargs["checks"]
assert isinstance(check, RBACCheck)
assert check.scene is RBACPermission.APP_VIEW_LAYOUT
assert isinstance(check.locator, PlainApp)
def test_check_acl_raises_when_app_or_mode_missing():
@@ -0,0 +1,26 @@
from collections.abc import Callable
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck
def rbac_checks(view: Callable[..., object]) -> tuple[RBACCheck, ...]:
current = view
while RBAC_CHECKS_ATTR not in current.__dict__:
wrapped = current.__dict__.get("__wrapped__")
if wrapped is None:
raise AssertionError(f"no decorator layer of {view!r} carries an rbac_checks bundle")
current = wrapped
return current.__dict__[RBAC_CHECKS_ATTR]
def all_rbac_checks(view: Callable[..., object]) -> list[RBACCheck]:
found: list[RBACCheck] = []
seen_bundle_ids: set[int] = set()
current: Callable[..., object] | None = view
while current is not None:
bundle = current.__dict__.get(RBAC_CHECKS_ATTR)
if bundle is not None and id(bundle) not in seen_bundle_ids:
seen_bundle_ids.add(id(bundle))
found.extend(bundle)
current = current.__dict__.get("__wrapped__")
return found
@@ -1,102 +1,143 @@
"""Guard against resource-scoped RBAC gates mounted on routes that carry no resource id.
"""Guard against resource-scoped RBAC gates mounted on routes their locator can't read.
``rbac_permission_required`` defaults to ``resource_required=True``, which makes
``_extract_resource_id`` raise ``ValueError`` when the matched path holds none of the
accepted identifiers. The request then fails with a 400 before the view ever runs, so the
endpoint is unreachable for every tenant with ``RBAC_ENABLED``. Creation endpoints and
other workspace-level actions must opt out with ``resource_required=False``.
Every RBAC gate is declared as one or more explicit ``RBACCheck(scene, Locator(...))``
bundles, via ``rbac_permission_required(RBACCheck(...), ...)`` or
``console_account_admission(rbac_checks=[RBACCheck(...), ...])``. Each locator reads a
path parameter — its explicit ``param`` argument, or its class default (``PlainApp`` reads
``app_id``, ``DatasetId`` reads ``dataset_id``, etc.; ``Workspace`` reads none). A route
whose URL doesn't carry that parameter can never resolve the check.
This check runs against the registered namespaces rather than the source text: both
decorators record their checks on the view function under ``RBAC_CHECKS_ATTR``, and reading
them back at runtime also covers resources built by a factory (the RBAC access-permission
endpoints in ``controllers/console/workspace/rbac.py``), which no AST scan can see.
"""
import ast
from pathlib import Path
from __future__ import annotations
CONTROLLERS_DIR = Path(__file__).resolve().parents[3] / "controllers"
from collections.abc import Iterator
# Mirrors the lookup order in controllers/common/wraps.py::_extract_resource_id.
ACCEPTED_PATH_ARGS = {
"APP": ("app_id", "agent_id", "resource_id"),
"DATASET": ("dataset_id", "pipeline_id", "resource_id"),
}
import controllers.console # noqa: F401 -- importing the package registers every console route
import controllers.openapi # noqa: F401 -- same for the user-scoped OpenAPI routes
from controllers.common.rbac import RBAC_CHECKS_ATTR, RBACCheck
from controllers.console import console_ns
from controllers.openapi import openapi_ns
NAMESPACES = (console_ns, openapi_ns)
HTTP_METHODS = ("delete", "get", "head", "options", "patch", "post", "put")
# Known violations tracked separately: DatasetDocumentSegmentBatchImportApi binds one class
# to both the dataset-scoped import route and the job-scoped status route, so every method
# on it is reachable at a path carrying only a job id. Its permission points are genuinely
# per-dataset, so it needs the route split rather than resource_required=False. Remove
# these entries with that fix.
# per-dataset, so it needs the route split rather than a Workspace() locator. Remove these
# entries with that fix.
KNOWN_VIOLATIONS = {
("console/datasets/datasets_segments.py", "DatasetDocumentSegmentBatchImportApi", "post"),
("console/datasets/datasets_segments.py", "DatasetDocumentSegmentBatchImportApi", "get"),
("DatasetDocumentSegmentBatchImportApi", "post"),
("DatasetDocumentSegmentBatchImportApi", "get"),
}
def _decorator_name(node: ast.Call) -> str:
func = node.func
parts = []
while isinstance(func, ast.Attribute):
parts.append(func.attr)
func = func.value
if isinstance(func, ast.Name):
parts.append(func.id)
return ".".join(reversed(parts))
# The generated access-permission endpoints, spelled out here so this guard fails loudly if
# the factory stops registering them (or renames a URL) rather than silently checking less.
RBAC_ACCESS_RESOURCE_PARTS = (("apps", "app_id"), ("datasets", "dataset_id"), ("agents", "agent_id"))
def _attribute_name(node: ast.expr | None) -> str | None:
return node.attr if isinstance(node, ast.Attribute) else None
def _rbac_access_urls() -> set[str]:
urls: set[str] = set()
for segment, id_param in RBAC_ACCESS_RESOURCE_PARTS:
resource = f"/workspaces/current/rbac/{segment}/<uuid:{id_param}>"
workspace = f"/workspaces/current/rbac/workspace/{segment}"
urls.update(
{
f"{resource}/access-policy",
f"{resource}/whitelist",
f"{resource}/whitelist_config",
f"{resource}/user-access-policies",
f"{resource}/users/<uuid:target_account_id>/access-policies",
f"{resource}/access-policies/<uuid:policy_id>/role-bindings",
f"{resource}/access-policies/<string:policy_id>/member-bindings",
f"{workspace}/access-policy",
f"{workspace}/access-policies/<uuid:policy_id>/role-bindings",
f"{workspace}/access-policies/<uuid:policy_id>/bindings",
f"{workspace}/access-policies/<uuid:policy_id>/member-bindings",
}
)
return urls
def _route_paths(class_node: ast.ClassDef) -> list[str]:
paths: list[str] = []
for decorator in class_node.decorator_list:
if isinstance(decorator, ast.Call) and _decorator_name(decorator).endswith(".route"):
paths.extend(
arg.value for arg in decorator.args if isinstance(arg, ast.Constant) and isinstance(arg.value, str)
)
return paths
def _class_attribute(resource: type, name: str) -> object | None:
"""Look up ``name`` on ``resource`` through the MRO without ``getattr``."""
for klass in resource.__mro__:
attributes = vars(klass)
if name in attributes:
return attributes[name]
return None
def _declared_checks(view: object) -> list[RBACCheck]:
"""Collect the RBAC checks a decorator recorded anywhere in this view's wrapper chain.
``functools.wraps`` copies the recorded tuple onto every outer wrapper, so the same
bundle shows up at several levels; identity tells the copies apart from a second gate.
"""
checks: list[RBACCheck] = []
seen: set[int] = set()
current = view
while current is not None:
attributes = vars(current)
recorded = attributes.get(RBAC_CHECKS_ATTR)
if recorded and id(recorded) not in seen:
seen.add(id(recorded))
checks.extend(recorded)
current = attributes.get("__wrapped__")
return checks
def _path_args(route: str) -> set[str]:
args = set()
args.update(segment.split(">")[0].split(":")[-1] for segment in route.split("<")[1:])
return args
return {segment.split(">")[0].split(":")[-1] for segment in route.split("<")[1:]}
def _resource_scoped_gates(method: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
scopes = []
for decorator in method.decorator_list:
if not (isinstance(decorator, ast.Call) and _decorator_name(decorator).endswith("rbac_permission_required")):
continue
keywords = {keyword.arg: keyword.value for keyword in decorator.keywords}
resource_required = keywords.get("resource_required")
if isinstance(resource_required, ast.Constant) and resource_required.value is False:
continue
scope = _attribute_name(decorator.args[0] if decorator.args else keywords.get("resource_type"))
if scope in ACCEPTED_PATH_ARGS:
scopes.append(scope)
return scopes
def _registered_methods() -> Iterator[tuple[type, str, object, tuple[str, ...]]]:
"""Yield ``(resource class, method name, view function, urls)`` for every routed method."""
for namespace in NAMESPACES:
for route in namespace.resources:
for method_name in HTTP_METHODS:
view = _class_attribute(route.resource, method_name)
if view is None:
continue
yield route.resource, method_name, view, tuple(route.urls)
def test_resource_scoped_rbac_gates_have_a_resource_id_in_the_route() -> None:
violations = []
for path in sorted(CONTROLLERS_DIR.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"))
for class_node in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
routes = _route_paths(class_node)
if not routes:
continue
methods = (node for node in class_node.body if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef))
for method in methods:
for scope in _resource_scoped_gates(method):
accepted = set(ACCEPTED_PATH_ARGS[scope])
unscoped = [route for route in routes if not _path_args(route) & accepted]
if not unscoped:
continue
key = (path.relative_to(CONTROLLERS_DIR).as_posix(), class_node.name, method.name)
if key in KNOWN_VIOLATIONS:
continue
violations.append(f"{key[0]}::{key[1]}.{key[2]} scope={scope} routes={unscoped}")
for resource, method_name, view, urls in _registered_methods():
if (resource.__name__, method_name) in KNOWN_VIOLATIONS:
continue
assert not violations, (
"resource-scoped rbac_permission_required on routes without a resource id; "
"pass resource_required=False for workspace-level actions:\n" + "\n".join(violations)
for check in _declared_checks(view):
# Only ``_ParamLocator`` subclasses store a path parameter; ``Workspace`` reads none.
required_param = vars(check.locator).get("param")
if not isinstance(required_param, str):
continue
unscoped = [url for url in urls if required_param not in _path_args(url)]
if unscoped:
violations.append(
f"{resource.__name__}.{method_name} "
f"locator={type(check.locator).__name__}({required_param!r}) "
f"scene={check.scene} routes={unscoped}"
)
assert not violations, "RBACCheck locator on a route that doesn't carry its required path parameter:\n" + "\n".join(
violations
)
def test_guard_reaches_the_generated_rbac_access_endpoints() -> None:
"""The factory-built endpoints are the reason this guard is runtime-based; prove it sees them."""
scanned_urls = {url for _, _, _, urls in _registered_methods() for url in urls}
missing = sorted(_rbac_access_urls() - scanned_urls)
assert not missing, "generated RBAC access routes are not reachable by this guard:\n" + "\n".join(missing)
@@ -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"]
@@ -51,6 +51,49 @@ 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]
@pytest.mark.parametrize(
("id_kwarg", "resource_id", "access_class"),
[
("app_id", "app-1", "AppAccess"),
("dataset_id", "dataset-1", "DatasetAccess"),
("agent_id", "agent-1", "AgentAccess"),
],
)
def test_initialize_created_app_rbac_access_task_targets_the_resource_that_was_passed(
monkeypatch: pytest.MonkeyPatch, id_kwarg: str, resource_id: str, access_class: str
):
import tasks.initialize_created_app_rbac_access_task as task_module
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
monkeypatch.setattr(
task_module.TenantService,
"iter_member_account_id_batches",
lambda tenant_id, batch_size, session: iter([["acct-1"]]),
)
rbac_service = task_module.enterprise_rbac_service.RBACService
access_clients = {
"AppAccess": rbac_service.AppAccess,
"DatasetAccess": rbac_service.DatasetAccess,
"AgentAccess": rbac_service.AgentAccess,
}
replace_calls = {}
for name, client in access_clients.items():
replace_calls[name] = MagicMock()
monkeypatch.setattr(client, "replace_user_access_policies", replace_calls[name])
initialize_created_app_rbac_access_task.run("tenant-1", "actor-1", **{id_kwarg: resource_id})
for name, mock in replace_calls.items():
if name != access_class:
mock.assert_not_called()
called = replace_calls[access_class]
called.assert_called_once()
assert called.call_args.kwargs[id_kwarg] == resource_id
assert called.call_args.kwargs["target_account_id"] is None
assert called.call_args.kwargs["payload"].account_ids == ["acct-1"]
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
@@ -87,6 +130,8 @@ 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"),
rbac.ResourceWhitelistConfigResource(resource_type=rbac.RBACResourceType.AGENT, resource_id="agent-2"),
]
configs = rbac.ResourceWhitelistConfigsResponse(
data=[
@@ -105,17 +150,29 @@ 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,
),
rbac.ResourceWhitelistConfigItem(
resource_type=rbac.RBACResourceType.AGENT,
resource_id="agent-2",
automatic_include_workspace_members=False,
),
]
)
batch_get = MagicMock(return_value=configs)
app_append = MagicMock()
dataset_append = MagicMock()
agent_append = MagicMock()
apply_config_overrides(monkeypatch, 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")
@@ -141,3 +198,11 @@ 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 [item.agent_id for item in agent_call["data"]] == ["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