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

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

No files matched your search

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