mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
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()
|
||||
|
||||
+8
-7
@@ -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)
|
||||
+20
-29
@@ -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):
|
||||
|
||||
+167
@@ -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
|
||||
Reference in new issue
Block a user