mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
522 lines
18 KiB
Python
522 lines
18 KiB
Python
"""Unit tests for granted operations access layer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
|
|
from langbot.pkg.api.application.api import ApplicationAPI
|
|
from langbot.pkg.api.application.contracts import Input, Operation
|
|
from langbot.pkg.api.application.granted_operations import (
|
|
GrantedOperation,
|
|
call,
|
|
domains,
|
|
list_granted,
|
|
requires_confirmation,
|
|
)
|
|
from langbot.pkg.api.authz import Permission
|
|
from langbot.pkg.api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
|
|
|
|
|
class DummyPayload(Input):
|
|
item_id: str = 'item-1'
|
|
|
|
|
|
class DummySecretInput(Input):
|
|
resource_uuid: str
|
|
include_secret: bool = False
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_host(monkeypatch):
|
|
monkeypatch.setattr('langbot.pkg.api.application.api.get_workspace_language', AsyncMock(return_value='en_US'))
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
@asynccontextmanager
|
|
async def tenant_scope(workspace):
|
|
yield
|
|
|
|
host = SimpleNamespace(
|
|
persistence_mgr=SimpleNamespace(tenant_scope=tenant_scope),
|
|
workspace_service=SimpleNamespace(
|
|
get_execution_binding=AsyncMock(
|
|
return_value=SimpleNamespace(instance_uuid='test-instance', placement_generation=1)
|
|
)
|
|
),
|
|
workspace_settings_service=SimpleNamespace(
|
|
effective_level=AsyncMock(return_value=1),
|
|
record=AsyncMock(),
|
|
),
|
|
logger=Mock(),
|
|
)
|
|
return host
|
|
|
|
|
|
def make_account_context(permissions: frozenset[str]) -> RequestContext:
|
|
return RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-1',
|
|
auth_type='cookie',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.ACCOUNT,
|
|
account_uuid='acc-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid='mem-1',
|
|
role='developer',
|
|
permissions=permissions,
|
|
),
|
|
)
|
|
|
|
|
|
# 1. 两种调用方的过滤规则
|
|
def test_assistant_filtering_rules(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
# Member with only RESOURCE_VIEW permission
|
|
view_only_context = make_account_context(frozenset({Permission.RESOURCE_VIEW.value}))
|
|
granted_ops = list_granted(mock_host, view_only_context, consumer='assistant')
|
|
granted_ids = {op.id for op in granted_ops}
|
|
|
|
# Should include in-scope read operations with exposure in {'assistant', 'all'}
|
|
assert 'bot.list' in granted_ids
|
|
assert 'agent.list' in granted_ids
|
|
# pipeline.migration.preview is exposure='assistant', requires RESOURCE_VIEW, account principal -> should be included!
|
|
assert 'pipeline.migration.preview' in granted_ids
|
|
|
|
# Should NOT include operations requiring RESOURCE_MANAGE
|
|
assert 'bot.create' not in granted_ids
|
|
assert 'bot.delete' not in granted_ids
|
|
|
|
# Should NOT include operations with exposure='none' (e.g. streaming, out-of-scope)
|
|
assert 'agent.debug.stream' not in granted_ids
|
|
assert 'workspace.member.remove' not in granted_ids
|
|
|
|
# Member with RESOURCE_MANAGE added
|
|
manage_context = make_account_context(frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}))
|
|
granted_manage = list_granted(mock_host, manage_context, consumer='assistant')
|
|
granted_manage_ids = {op.id for op in granted_manage}
|
|
assert 'bot.create' in granted_manage_ids
|
|
assert 'bot.delete' in granted_manage_ids
|
|
|
|
|
|
def test_processor_filtering_rules(mock_host, caplog):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
processor_context = RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-proc',
|
|
auth_type='processor',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.PROCESSOR,
|
|
processor_kind='agent',
|
|
processor_uuid='agent-1',
|
|
run_id='run-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}),
|
|
),
|
|
allowed_operation_ids=frozenset({'bot.list'}),
|
|
)
|
|
|
|
requested_granted = [
|
|
'bot.list', # exposure == 'all' and in allowed_operation_ids -> kept
|
|
'agent.create', # exposure == 'all' but NOT in allowed_operation_ids -> discarded
|
|
'mcp.server.create', # exposure == 'assistant' -> discarded
|
|
'pipeline.migration.preview', # exposure == 'assistant' -> discarded
|
|
'agent.debug.stream', # streaming -> discarded
|
|
'nonexistent.operation.id', # unknown -> discarded
|
|
]
|
|
|
|
import logging
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
granted_ops = list_granted(mock_host, processor_context, consumer='processor', granted=requested_granted)
|
|
|
|
granted_ids = [op.id for op in granted_ops]
|
|
assert granted_ids == ['bot.list']
|
|
|
|
# Verify warning logged for discarded operations
|
|
assert 'Discarded' in caplog.text
|
|
assert 'agent.create' in caplog.text
|
|
assert 'mcp.server.create' in caplog.text
|
|
assert 'nonexistent.operation.id' in caplog.text
|
|
|
|
|
|
def test_processor_granted_exceeding_context_is_discarded_with_warning(mock_host, caplog):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
processor_context = RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-proc',
|
|
auth_type='processor',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.PROCESSOR,
|
|
processor_kind='agent',
|
|
processor_uuid='agent-1',
|
|
run_id='run-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=frozenset({Permission.RESOURCE_VIEW.value}),
|
|
),
|
|
allowed_operation_ids=frozenset({'bot.list'}),
|
|
)
|
|
|
|
import logging
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
ops = list_granted(
|
|
mock_host,
|
|
processor_context,
|
|
consumer='processor',
|
|
granted=['bot.list', 'bot.delete', 'agent.create'],
|
|
)
|
|
|
|
assert [op.id for op in ops] == ['bot.list']
|
|
assert 'Discarded 2 invalid operation(s) for processor' in caplog.text
|
|
assert 'bot.delete' in caplog.text
|
|
assert 'agent.create' in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_processor_call_outside_context_allowed_operation_ids_returns_not_found(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
processor_context = RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-proc',
|
|
auth_type='processor',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.PROCESSOR,
|
|
processor_kind='agent',
|
|
processor_uuid='agent-1',
|
|
run_id='run-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=frozenset({Permission.RESOURCE_VIEW.value}),
|
|
),
|
|
allowed_operation_ids=frozenset({'bot.list'}),
|
|
)
|
|
|
|
# Calling bot.delete with granted=['bot.delete'] must NOT elevate permissions or allow execution
|
|
res = await call(
|
|
mock_host,
|
|
processor_context,
|
|
'bot.delete',
|
|
{'bot_uuid': 'bot-1'},
|
|
consumer='processor',
|
|
granted=['bot.delete'],
|
|
)
|
|
assert res['ok'] is False
|
|
assert res['error']['code'] == 'not_found'
|
|
assert 'Operation not found' in res['error']['message']
|
|
|
|
|
|
def test_processor_fallback_to_context_allowed_operation_ids(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
processor_context = RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-proc',
|
|
auth_type='processor',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.PROCESSOR,
|
|
processor_kind='agent',
|
|
processor_uuid='agent-1',
|
|
run_id='run-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=frozenset({Permission.RESOURCE_VIEW.value}),
|
|
),
|
|
allowed_operation_ids=frozenset({'bot.list', 'model.llm.list'}),
|
|
)
|
|
|
|
# granted=None -> falls back to context.allowed_operation_ids
|
|
ops = list_granted(mock_host, processor_context, consumer='processor', granted=None)
|
|
assert {op.id for op in ops} == {'bot.list', 'model.llm.list'}
|
|
|
|
|
|
# 2. include_secret 被去掉
|
|
def test_include_secret_stripped_from_schema(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
context = make_account_context(frozenset({Permission.RESOURCE_VIEW.value}))
|
|
ops = list_granted(mock_host, context, consumer='assistant')
|
|
|
|
# Find bot.get, which defines include_secret in its original Input schema
|
|
bot_get_op = next((op for op in ops if op.id == 'bot.get'), None)
|
|
assert bot_get_op is not None
|
|
assert 'include_secret' not in bot_get_op.input_schema.get('properties', {})
|
|
assert 'include_secret' not in bot_get_op.input_schema.get('required', [])
|
|
|
|
# Find model.llm.get
|
|
llm_get_op = next((op for op in ops if op.id == 'model.llm.get'), None)
|
|
assert llm_get_op is not None
|
|
assert 'include_secret' not in llm_get_op.input_schema.get('properties', {})
|
|
assert 'include_secret' not in llm_get_op.input_schema.get('required', [])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_include_secret_stripped_from_call_arguments(mock_host):
|
|
received_args = {}
|
|
|
|
async def mock_handler(ap, ctx, payload):
|
|
received_args.update(payload.model_dump())
|
|
return {'uuid': payload.bot_uuid, 'token': 'mock-secret-token'}
|
|
|
|
class DummyBotGetInput(Input):
|
|
bot_uuid: str
|
|
include_secret: bool = False
|
|
|
|
test_op = Operation(
|
|
'bot.get',
|
|
Permission.RESOURCE_VIEW,
|
|
mock_handler,
|
|
DummyBotGetInput,
|
|
dict,
|
|
side_effect=False,
|
|
)
|
|
api = ApplicationAPI(mock_host, operations=[test_op])
|
|
mock_host.application_api = api
|
|
|
|
ctx = make_account_context(frozenset({Permission.RESOURCE_VIEW.value}))
|
|
res = await call(
|
|
mock_host,
|
|
ctx,
|
|
'bot.get',
|
|
{'bot_uuid': 'bot-123', 'include_secret': True},
|
|
consumer='assistant',
|
|
)
|
|
assert res['ok'] is True
|
|
# Verify handler received arguments without include_secret
|
|
assert received_args == {'bot_uuid': 'bot-123', 'include_secret': False}
|
|
|
|
|
|
# 3. 不在允许列表里的操作返回 not_found
|
|
@pytest.mark.asyncio
|
|
async def test_not_found_for_disallowed_or_unknown_operations(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
ctx = make_account_context(frozenset({Permission.RESOURCE_VIEW.value}))
|
|
|
|
# 1. Operation exists, but member lacks permission (bot.create requires RESOURCE_MANAGE)
|
|
res1 = await call(mock_host, ctx, 'bot.create', {'bot_data': {}}, consumer='assistant')
|
|
assert res1['ok'] is False
|
|
assert res1['error']['code'] == 'not_found'
|
|
assert 'Operation not found' in res1['error']['message']
|
|
|
|
# 2. Operation exists, but exposure is 'none' (workspace.member.remove)
|
|
res2 = await call(mock_host, ctx, 'workspace.member.remove', {'account_uuid': 'acc-2'}, consumer='assistant')
|
|
assert res2['ok'] is False
|
|
assert res2['error']['code'] == 'not_found'
|
|
|
|
# 3. Completely non-existent operation
|
|
res3 = await call(mock_host, ctx, 'completely.fake.op', {}, consumer='assistant')
|
|
assert res3['ok'] is False
|
|
assert res3['error']['code'] == 'not_found'
|
|
|
|
# 4. Processor calling operation not in granted list
|
|
proc_ctx = RequestContext(
|
|
instance_uuid='test-instance',
|
|
placement_generation=1,
|
|
request_id='req-proc',
|
|
auth_type='processor',
|
|
principal=PrincipalContext(
|
|
principal_type=PrincipalType.PROCESSOR,
|
|
processor_kind='agent',
|
|
processor_uuid='agent-1',
|
|
run_id='run-1',
|
|
),
|
|
workspace=WorkspaceContext(
|
|
workspace_uuid='ws-1',
|
|
membership_uuid=None,
|
|
role=None,
|
|
permissions=frozenset({Permission.RESOURCE_VIEW.value}),
|
|
),
|
|
allowed_operation_ids=frozenset({'bot.list'}),
|
|
)
|
|
res4 = await call(mock_host, proc_ctx, 'agent.list', {}, consumer='processor', granted=['bot.list'])
|
|
assert res4['ok'] is False
|
|
assert res4['error']['code'] == 'not_found'
|
|
|
|
|
|
# 4. 结果经过脱敏
|
|
@pytest.mark.asyncio
|
|
async def test_result_secrets_redacted(mock_host):
|
|
async def mock_handler(ap, ctx, payload):
|
|
return {
|
|
'id': 'bot-1',
|
|
'api_key': 'super-secret-key-999',
|
|
'nested': {
|
|
'token': 'bearer-secret-token',
|
|
'name': 'public-name',
|
|
},
|
|
}
|
|
|
|
test_op = Operation(
|
|
'bot.get',
|
|
Permission.RESOURCE_VIEW,
|
|
mock_handler,
|
|
Input,
|
|
dict,
|
|
side_effect=False,
|
|
)
|
|
api = ApplicationAPI(mock_host, operations=[test_op])
|
|
mock_host.application_api = api
|
|
|
|
ctx = make_account_context(frozenset({Permission.RESOURCE_VIEW.value}))
|
|
res = await call(mock_host, ctx, 'bot.get', {}, consumer='assistant')
|
|
|
|
assert res['ok'] is True
|
|
assert res['result']['api_key'] == '***'
|
|
assert res['result']['nested']['token'] == '***'
|
|
assert res['result']['nested']['name'] == 'public-name'
|
|
assert res['operation']['id'] == 'bot.get'
|
|
assert res['operation']['risk'] == 'read'
|
|
assert res['operation']['side_effect'] is False
|
|
|
|
|
|
# 5. 错误信息里没有输入值
|
|
@pytest.mark.asyncio
|
|
async def test_error_message_does_not_contain_input_values(mock_host):
|
|
async def failing_handler(ap, ctx, payload):
|
|
# Service throws an error that echoes input parameters
|
|
raise ValueError(f'Item {payload.item_id} not found in partition secret_cluster_xyz')
|
|
|
|
test_op = Operation(
|
|
'bot.delete',
|
|
Permission.RESOURCE_MANAGE,
|
|
failing_handler,
|
|
DummyPayload,
|
|
dict,
|
|
side_effect=True,
|
|
)
|
|
api = ApplicationAPI(mock_host, operations=[test_op])
|
|
mock_host.application_api = api
|
|
|
|
ctx = make_account_context(frozenset({Permission.RESOURCE_MANAGE.value}))
|
|
sensitive_input = 'my-sensitive-token-12345'
|
|
res = await call(
|
|
mock_host,
|
|
ctx,
|
|
'bot.delete',
|
|
{'item_id': sensitive_input},
|
|
consumer='assistant',
|
|
)
|
|
assert res['ok'] is False
|
|
assert sensitive_input not in res['error']['message']
|
|
assert '[REDACTED]' in res['error']['message']
|
|
|
|
|
|
# 6. domains 的统计结果
|
|
def test_domains_aggregation(mock_host):
|
|
api = ApplicationAPI(mock_host)
|
|
mock_host.application_api = api
|
|
|
|
ctx = make_account_context(frozenset({Permission.RESOURCE_VIEW.value, Permission.RESOURCE_MANAGE.value}))
|
|
granted_ops = list_granted(mock_host, ctx, consumer='assistant')
|
|
|
|
domain_summary = domains(granted_ops)
|
|
assert isinstance(domain_summary, list)
|
|
assert len(domain_summary) > 0
|
|
|
|
total_counted = sum(d['count'] for d in domain_summary)
|
|
assert total_counted == len(granted_ops)
|
|
|
|
# Check structure
|
|
for d in domain_summary:
|
|
assert 'domain' in d
|
|
assert 'label' in d
|
|
assert 'zh_Hans' in d['label'] and 'en_US' in d['label']
|
|
assert 'summary' in d
|
|
assert 'count' in d
|
|
assert d['count'] > 0
|
|
|
|
# Empty list
|
|
assert domains([]) == []
|
|
|
|
|
|
def test_requires_confirmation():
|
|
# Read operations -> False
|
|
assert requires_confirmation('bot.list') is False
|
|
assert requires_confirmation('model.llm.get') is False
|
|
|
|
# Write / dangerous operations -> True
|
|
assert requires_confirmation('bot.create') is True
|
|
assert requires_confirmation('bot.delete') is True
|
|
|
|
# With dict or object
|
|
assert requires_confirmation({'risk': 'read'}) is False
|
|
assert requires_confirmation({'risk': 'write'}) is True
|
|
assert requires_confirmation({'risk': 'dangerous'}) is True
|
|
|
|
|
|
def test_granted_operation_serialization():
|
|
op = GrantedOperation(
|
|
id='test.op',
|
|
domain='test',
|
|
label={'zh_Hans': '测试', 'en_US': 'Test'},
|
|
summary='Test summary',
|
|
description='Test description',
|
|
risk='read',
|
|
side_effect=False,
|
|
permission='resource.view',
|
|
input_schema={'type': 'object', 'properties': {}},
|
|
output_schema={'type': 'object'},
|
|
)
|
|
# Direct serialization via json.dumps(asdict(op))
|
|
serialized = json.dumps(asdict(op), ensure_ascii=False)
|
|
data = json.loads(serialized)
|
|
assert data['id'] == 'test.op'
|
|
assert data['label']['zh_Hans'] == '测试'
|
|
|
|
# as_dict method
|
|
assert op.as_dict() == data
|
|
|
|
# Mapping access
|
|
assert op['id'] == 'test.op'
|
|
assert op.get('domain') == 'test'
|
|
assert dict(op)['permission'] == 'resource.view'
|
|
|
|
|
|
def test_error_message_sanitization_does_not_corrupt_short_inputs():
|
|
from langbot.pkg.api.application.granted_operations import _sanitize_error_message
|
|
|
|
msg = 'Application resource 1 failed on shard a'
|
|
sanitized = _sanitize_error_message(msg, {'id': '1', 'shard': 'a', 'count': 2})
|
|
assert sanitized == 'Application resource 1 failed on shard a'
|
|
|
|
msg_with_secret = 'Application resource secret_token_xyz failed on shard a'
|
|
sanitized_secret = _sanitize_error_message(msg_with_secret, {'token': 'secret_token_xyz', 'id': '1'})
|
|
assert sanitized_secret == 'Application resource [REDACTED] failed on shard a'
|