feat(tenancy): implement workspace isolation

This commit is contained in:
Junyan Qin
2026-07-19 09:58:59 +08:00
parent 9eb292992d
commit c6f826fe2d
271 changed files with 31162 additions and 6106 deletions
+52 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -65,12 +66,43 @@ def fake_bot_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
app.apikey_service.authenticate_api_key = AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-test',
placement_generation=1,
api_key_uuid='api-key-test',
workspace_uuid='workspace-test',
permissions=frozenset(
{
'resource.view',
'resource.manage',
'runtime.operate',
'provider_secret.manage',
}
),
)
)
# Bot service
app.bot_service = Mock()
@@ -198,6 +230,25 @@ class TestBotLogsEndpoint:
assert 'logs' in data['data']
assert 'total_count' in data['data']
@pytest.mark.asyncio
async def test_viewer_cannot_read_message_logs(self, quart_test_client, fake_bot_app):
access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
original_role = access.membership.role
access.membership.role = 'viewer'
fake_bot_app.bot_service.list_event_logs.reset_mock()
try:
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
headers={'Authorization': 'Bearer test_token'},
json={'from_index': -1, 'max_count': 10},
)
finally:
access.membership.role = original_role
assert response.status_code == 403
assert (await response.get_json())['code'] == 'permission_denied'
fake_bot_app.bot_service.list_event_logs.assert_not_awaited()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
@@ -0,0 +1,83 @@
"""Authorization tests for sensitive Box runtime observability."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import quart
from langbot.pkg.api.http.controller.groups.box import BoxRouterGroup
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
def _access(account_uuid: str):
return SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(
uuid=f'member-{account_uuid}',
role='viewer' if account_uuid == 'viewer-account' else 'owner',
projection_revision=1,
),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
@pytest.fixture
async def box_security_api():
accounts = {
'viewer-token': SimpleNamespace(uuid='viewer-account', user='viewer@example.com'),
'owner-token': SimpleNamespace(uuid='owner-account', user='owner@example.com'),
}
application = Mock()
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
)
application.box_service.get_status = AsyncMock(return_value={'enabled': True})
application.box_service.get_sessions = AsyncMock(return_value=[{'session_id': 'private-session'}])
application.box_service.get_recent_errors = Mock(return_value=[{'error': 'private error'}])
quart_app = quart.Quart(__name__)
router = BoxRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
def _headers(token: str) -> dict[str, str]:
return {
'Authorization': f'Bearer {token}',
'X-Workspace-Id': WORKSPACE_UUID,
}
@pytest.mark.asyncio
async def test_viewer_can_read_status_but_not_sessions_or_errors(box_security_api):
application, client = box_security_api
status = await client.get('/api/v1/box/status', headers=_headers('viewer-token'))
sessions = await client.get('/api/v1/box/sessions', headers=_headers('viewer-token'))
errors = await client.get('/api/v1/box/errors', headers=_headers('viewer-token'))
assert status.status_code == 200
assert sessions.status_code == 403
assert errors.status_code == 403
application.box_service.get_sessions.assert_not_awaited()
application.box_service.get_recent_errors.assert_not_called()
@pytest.mark.asyncio
async def test_owner_can_audit_box_sessions_and_errors(box_security_api):
application, client = box_security_api
sessions = await client.get('/api/v1/box/sessions', headers=_headers('owner-token'))
errors = await client.get('/api/v1/box/errors', headers=_headers('owner-token'))
assert sessions.status_code == 200
assert errors.status_code == 200
application.box_service.get_sessions.assert_awaited_once()
application.box_service.get_recent_errors.assert_called_once()
+109 -7
View File
@@ -8,8 +8,11 @@ Run: uv run pytest tests/integration/api/test_embed.py -q
from __future__ import annotations
import json
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -80,10 +83,18 @@ def fake_embed_app():
mock_runtime_bot = Mock()
mock_runtime_bot.bot_entity = mock_bot_entity
mock_runtime_bot.execution_context = SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
# Platform manager with bots
app.platform_mgr = Mock()
app.platform_mgr.bots = [mock_runtime_bot]
app.platform_mgr.resolve_public_bot = AsyncMock(
side_effect=lambda route_key: mock_runtime_bot if route_key == mock_bot_entity.uuid else None
)
# WebSocket proxy bot with adapter
mock_websocket_adapter = Mock()
@@ -94,6 +105,16 @@ def fake_embed_app():
mock_ws_proxy_bot = Mock()
mock_ws_proxy_bot.adapter = mock_websocket_adapter
app.platform_mgr.websocket_proxy_bot = mock_ws_proxy_bot
app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=mock_ws_proxy_bot)
app.workspace_service = SimpleNamespace(
get_execution_binding=AsyncMock(
return_value=SimpleNamespace(
instance_uuid='instance-test',
workspace_uuid='workspace-test',
placement_generation=1,
)
)
)
# Monitoring service for feedback
app.monitoring_service = Mock()
@@ -117,12 +138,13 @@ class TestEmbedWidgetEndpoint:
"""Tests for widget.js endpoint."""
@pytest.mark.asyncio
async def test_get_widget_js_success(self, quart_test_client):
async def test_get_widget_js_success(self, quart_test_client, fake_embed_app):
"""GET /api/v1/embed/{bot_uuid}/widget.js returns JS."""
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js')
assert response.status_code == 200
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
@@ -203,9 +225,8 @@ class TestEmbedMessagesEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
@@ -253,9 +274,8 @@ class TestEmbedResetEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
'test-pipeline-uuid', 'person', SESSION_ID
)
proxy_bot = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value
proxy_bot.adapter.reset_session.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_reset_session_requires_session_id(self, quart_test_client):
@@ -316,3 +336,85 @@ class TestEmbedFeedbackEndpoint:
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedWebSocketEndpoint:
"""The public socket authenticates before resolving shared runtime state."""
@pytest.mark.asyncio
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
connected = json.loads(await websocket.receive())
assert connected['type'] == 'connected'
assert connected['bot_uuid'] == 'a1b2c3d4-5678-90ab-cdef-123456789abc'
await websocket.send(json.dumps({'type': 'disconnect'}))
fake_embed_app.workspace_service.get_execution_binding.assert_awaited_with(
'workspace-test',
expected_generation=1,
)
@pytest.mark.asyncio
async def test_rejects_non_auth_first_frame_before_runtime_lookup(self, quart_test_client, fake_embed_app):
fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'message', 'message': []}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejects_invalid_turnstile_session_before_runtime_lookup(self, quart_test_client, fake_embed_app):
fake_embed_app.platform_mgr.get_websocket_proxy_bot.reset_mock()
config = fake_embed_app.platform_mgr.resolve_public_bot.side_effect(
'a1b2c3d4-5678-90ab-cdef-123456789abc'
).bot_entity.adapter_config
config['turnstile_secret_key'] = 'test-secret'
try:
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': 'invalid'}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
finally:
config['turnstile_secret_key'] = ''
fake_embed_app.platform_mgr.get_websocket_proxy_bot.assert_not_awaited()
@pytest.mark.asyncio
async def test_rejects_message_when_bot_is_disabled_after_connect(self, quart_test_client, fake_embed_app):
runtime_bot = fake_embed_app.platform_mgr.resolve_public_bot.side_effect('a1b2c3d4-5678-90ab-cdef-123456789abc')
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
adapter.handle_websocket_message.reset_mock()
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'authenticate', 'token': ''}))
assert json.loads(await websocket.receive())['type'] == 'connected'
runtime_bot.bot_entity.enable = False
try:
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Bot is unavailable'}
finally:
runtime_bot.bot_entity.enable = True
adapter.handle_websocket_message.assert_not_awaited()
+23 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -69,7 +70,28 @@ def fake_knowledge_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
account = SimpleNamespace(
uuid='00000000-0000-0000-0000-000000000001',
user='test@example.com',
)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
execution=SimpleNamespace(
instance_uuid='instance-knowledge-api',
placement_generation=1,
),
workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
membership=SimpleNamespace(
uuid='00000000-0000-0000-0000-000000000010',
role='owner',
projection_revision=0,
),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+21 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,7 +67,26 @@ def fake_monitoring_app():
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(
return_value=SimpleNamespace(
uuid='account-uuid',
user='test@example.com',
email='test@example.com',
)
)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
execution=SimpleNamespace(instance_uuid='instance', placement_generation=1),
workspace=SimpleNamespace(uuid='00000000-0000-0000-0000-00000000000a'),
membership=SimpleNamespace(
uuid='membership-uuid',
role='owner',
projection_revision=1,
),
)
)
)
# Monitoring service
app.monitoring_service = Mock()
+173 -1
View File
@@ -9,10 +9,13 @@ Run: uv run pytest tests/integration/api/test_pipelines.py -q
from __future__ import annotations
import json
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration
@@ -54,6 +57,7 @@ def mock_circular_import_chain():
):
# Import groups after mocking to populate preregistered_groups
import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as _websocket_chat # noqa: E402, F401
yield
@@ -75,10 +79,25 @@ def fake_pipeline_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
@@ -119,6 +138,15 @@ def fake_pipeline_app():
app.bot_service.get_bots = AsyncMock(return_value=[])
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
# Workspace-scoped dashboard WebSocket proxy
websocket_adapter = Mock()
websocket_adapter.get_websocket_messages = Mock(return_value=[])
websocket_adapter.reset_session = Mock()
websocket_adapter.handle_websocket_message = AsyncMock()
websocket_proxy_bot = Mock(adapter=websocket_adapter)
app.platform_mgr = Mock()
app.platform_mgr.get_websocket_proxy_bot = AsyncMock(return_value=websocket_proxy_bot)
# MCP service (for extensions endpoint)
app.mcp_service = Mock()
app.mcp_service.get_mcp_servers = AsyncMock(return_value=[])
@@ -278,3 +306,147 @@ class TestPipelineExtensionsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_get_extensions_redacts_available_plugin_secrets(
self,
quart_test_client,
fake_pipeline_app,
):
connector = fake_pipeline_app.plugin_connector
raw_plugin = {
'plugin_config': {'apiKey': 'plugin-secret'},
'debug': {'plugin_debug_key': 'debug-secret'},
}
connector.list_plugins.return_value = [raw_plugin]
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions',
headers={'Authorization': 'Bearer test_token'},
)
finally:
connector.list_plugins.return_value = []
assert response.status_code == 200
plugin = (await response.get_json())['data']['available_plugins'][0]
assert plugin['plugin_config']['apiKey'] == '***'
assert plugin['debug']['plugin_debug_key'] == '***'
assert raw_plugin['plugin_config']['apiKey'] == 'plugin-secret'
@pytest.mark.asyncio
async def test_get_extensions_hides_connector_bound_to_another_workspace(
self,
quart_test_client,
fake_pipeline_app,
):
connector = fake_pipeline_app.plugin_connector
original_enabled = connector.is_enable_plugin
connector.is_enable_plugin = True
connector.require_workspace_context.reset_mock()
connector.list_plugins.reset_mock()
connector.require_workspace_context.side_effect = WorkspaceNotFoundError('Plugin resource not found')
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions',
headers={'Authorization': 'Bearer test_token'},
)
finally:
connector.require_workspace_context.side_effect = None
connector.is_enable_plugin = original_enabled
assert response.status_code == 404
connector.list_plugins.assert_not_awaited()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineDashboardWebSocket:
@pytest.mark.asyncio
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(
json.dumps(
{
'type': 'authenticate',
'token': 'test_token',
'workspace_uuid': 'workspace-test',
}
)
)
connected = json.loads(await websocket.receive())
assert connected['type'] == 'connected'
assert connected['pipeline_uuid'] == 'test-pipeline-uuid'
await websocket.send(json.dumps({'type': 'disconnect'}))
fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.assert_awaited_with(
'account-test',
'workspace-test',
)
fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
@pytest.mark.asyncio
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(json.dumps({'type': 'message', 'message': []}))
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
@pytest.mark.asyncio
async def test_websocket_rechecks_revocable_membership_before_each_message(
self,
quart_test_client,
fake_pipeline_app,
):
access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
adapter = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
original_role = access.membership.role
adapter.handle_websocket_message.reset_mock()
try:
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
) as websocket:
await websocket.send(
json.dumps(
{
'type': 'authenticate',
'token': 'test_token',
'workspace_uuid': 'workspace-test',
}
)
)
assert json.loads(await websocket.receive())['type'] == 'connected'
access.membership.role = 'viewer'
await websocket.send(json.dumps({'type': 'message', 'message': []}))
assert json.loads(await websocket.receive()) == {
'type': 'error',
'message': 'Unauthorized',
}
finally:
access.membership.role = original_role
adapter.handle_websocket_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_dashboard_history_requires_runtime_permission(self, quart_test_client, fake_pipeline_app):
access = fake_pipeline_app.workspace_collaboration_service.resolve_account_workspace.return_value
original_role = access.membership.role
access.membership.role = 'viewer'
try:
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/ws/messages/person',
headers={
'Authorization': 'Bearer test_token',
'X-Workspace-Id': 'workspace-test',
},
)
finally:
access.membership.role = original_role
assert response.status_code == 403
@@ -0,0 +1,248 @@
"""Security regression tests for plugin configuration HTTP responses."""
from __future__ import annotations
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, call
import pytest
import quart
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
RAW_CONFIG = {
'apiKey': 'api-secret',
'nested': {
'headers': {'Authorization': 'Bearer nested-secret', 'Accept': 'application/json'},
'refresh_token': 'refresh-secret',
'public_key': 'public-material',
'tokenizer': 'not-a-secret',
},
'credentials': {'username': 'service-user', 'password': 'service-password'},
'secret_list': ['first-secret', {'value': 'second-secret'}],
'empty_secret': '',
'enabled': True,
}
def _access(account_uuid: str):
roles = {
'viewer-account': 'viewer',
'operator-account': 'operator',
'manager-account': 'developer',
}
return SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(
uuid=f'membership-{account_uuid}',
role=roles[account_uuid],
projection_revision=1,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
@pytest.fixture(scope='module')
def plugin_module():
"""Import the plugin router without following the core HTTP cycle."""
from tests.utils.import_isolation import MockLifecycleControlScope, isolated_sys_modules
class FakeMinimalApplication:
pass
mock_app = Mock(Application=FakeMinimalApplication)
mock_entities = Mock(LifecycleControlScope=MockLifecycleControlScope)
clear = [
'langbot.pkg.core.taskmgr',
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.plugins',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.plugins as plugins
yield plugins
@pytest.fixture
async def plugin_security_api(plugin_module):
viewer = SimpleNamespace(uuid='viewer-account', user='viewer@example.com')
operator = SimpleNamespace(uuid='operator-account', user='operator@example.com')
manager = SimpleNamespace(uuid='manager-account', user='manager@example.com')
accounts = {
'viewer-token': viewer,
'operator-token': operator,
'manager-token': manager,
}
application = Mock()
application.user_service.get_authenticated_account = AsyncMock(side_effect=lambda token: accounts[token])
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=lambda account_uuid, _workspace_uuid: _access(account_uuid)
)
application.apikey_service.verify_api_key = AsyncMock(return_value=False)
application.instance_config.data = {
'plugin': {'display_plugin_debug_url': 'http://localhost:5401'},
'system': {'limitation': {}},
}
raw_plugin = {
'author': 'example',
'name': 'secure-plugin',
'plugin_config': RAW_CONFIG,
'debug': {'plugin_debug_key': 'list-debug-secret'},
}
application.plugin_connector.require_workspace_context = AsyncMock()
application.plugin_connector.list_plugins = AsyncMock(return_value=[raw_plugin])
application.plugin_connector.get_plugin_info = AsyncMock(return_value=raw_plugin)
application.plugin_connector.get_debug_info = AsyncMock(return_value={'plugin_debug_key': 'runtime-debug-secret'})
application.plugin_connector.get_plugin_logs = AsyncMock(return_value=['private runtime line'])
application.plugin_connector.set_plugin_config = AsyncMock()
persistence_result = Mock()
persistence_result.scalar_one_or_none.return_value = RAW_CONFIG
application.persistence_mgr.execute_async = AsyncMock(return_value=persistence_result)
quart_app = quart.Quart(__name__)
router = plugin_module.PluginsRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client(), raw_plugin
def _headers(token: str) -> dict[str, str]:
return {
'Authorization': f'Bearer {token}',
'X-Workspace-Id': WORKSPACE_UUID,
}
def test_recursive_redaction_preserves_structure_without_mutating_input(plugin_module):
redacted = plugin_module.redact_plugin_secrets(RAW_CONFIG)
assert redacted['apiKey'] == '***'
assert redacted['nested']['headers'] == {
'Authorization': '***',
'Accept': 'application/json',
}
assert redacted['nested']['refresh_token'] == '***'
assert redacted['nested']['public_key'] == 'public-material'
assert redacted['nested']['tokenizer'] == 'not-a-secret'
assert redacted['credentials'] == {'username': '***', 'password': '***'}
assert redacted['secret_list'] == ['***', {'value': '***'}]
assert redacted['empty_secret'] == ''
assert redacted['enabled'] is True
assert RAW_CONFIG['apiKey'] == 'api-secret'
assert RAW_CONFIG['nested']['headers']['Authorization'] == 'Bearer nested-secret'
with pytest.raises(ValueError, match='no existing value'):
plugin_module.restore_plugin_secret_placeholders({'api_key': '***'}, {})
@pytest.mark.asyncio
async def test_viewer_plugin_reads_are_recursively_redacted(plugin_security_api):
application, client, raw_plugin = plugin_security_api
list_response = await client.get('/api/v1/plugins', headers=_headers('viewer-token'))
detail_response = await client.get(
'/api/v1/plugins/example/secure-plugin',
headers=_headers('viewer-token'),
)
config_response = await client.get(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('viewer-token'),
)
assert list_response.status_code == 200
assert detail_response.status_code == 200
assert config_response.status_code == 200
listed_plugin = (await list_response.get_json())['data']['plugins'][0]
detailed_plugin = (await detail_response.get_json())['data']['plugin']
config = (await config_response.get_json())['data']['config']
for plugin in (listed_plugin, detailed_plugin):
assert plugin['plugin_config']['apiKey'] == '***'
assert plugin['debug']['plugin_debug_key'] == '***'
assert config['apiKey'] == '***'
assert config['nested']['headers']['Authorization'] == '***'
assert raw_plugin['plugin_config']['apiKey'] == 'api-secret'
application.plugin_connector.require_workspace_context.assert_awaited()
@pytest.mark.asyncio
async def test_manager_read_is_redacted_but_write_preserves_or_replaces_secrets(
plugin_security_api,
plugin_module,
):
application, client, _ = plugin_security_api
read_response = await client.get(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
)
masked_update = plugin_module.redact_plugin_secrets(RAW_CONFIG)
masked_update['enabled'] = False
preserved_write = await client.put(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
json=masked_update,
)
replacement = copy.deepcopy(RAW_CONFIG)
replacement['apiKey'] = 'replacement-secret'
replaced_write = await client.put(
'/api/v1/plugins/example/secure-plugin/config',
headers=_headers('manager-token'),
json=replacement,
)
preserved = copy.deepcopy(RAW_CONFIG)
preserved['enabled'] = False
assert read_response.status_code == 200
assert (await read_response.get_json())['data']['config']['apiKey'] == '***'
assert preserved_write.status_code == 200
assert replaced_write.status_code == 200
assert application.plugin_connector.set_plugin_config.await_args_list == [
call('example', 'secure-plugin', preserved),
call('example', 'secure-plugin', replacement),
]
@pytest.mark.asyncio
async def test_debug_key_requires_resource_manage_permission(plugin_security_api):
application, client, _ = plugin_security_api
viewer_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('viewer-token'))
operator_denied = await client.get('/api/v1/plugins/debug-info', headers=_headers('operator-token'))
application.plugin_connector.get_debug_info.assert_not_awaited()
allowed = await client.get('/api/v1/plugins/debug-info', headers=_headers('manager-token'))
assert viewer_denied.status_code == 403
assert operator_denied.status_code == 403
assert allowed.status_code == 200
assert (await allowed.get_json())['data'] == {
'debug_url': 'http://localhost:5401',
'plugin_debug_key': 'runtime-debug-secret',
}
application.plugin_connector.get_debug_info.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_viewer_cannot_read_plugin_runtime_logs(plugin_security_api):
application, client, _ = plugin_security_api
response = await client.get(
'/api/v1/plugins/example/secure-plugin/logs',
headers=_headers('viewer-token'),
)
assert response.status_code == 403
assert (await response.get_json())['code'] == 'permission_denied'
application.plugin_connector.get_plugin_logs.assert_not_awaited()
+17 -1
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from types import SimpleNamespace
from tests.factories import FakeApp
@@ -66,10 +67,25 @@ def fake_provider_app():
)
# Auth services
account = SimpleNamespace(uuid='account-test', user='test@example.com')
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.user_service.get_user_by_email = AsyncMock(return_value=account)
app.user_service.get_authenticated_account = AsyncMock(return_value=account)
app.workspace_collaboration_service = SimpleNamespace(
resolve_account_workspace=AsyncMock(
return_value=SimpleNamespace(
workspace=SimpleNamespace(uuid='workspace-test'),
membership=SimpleNamespace(
uuid='membership-test',
role='owner',
projection_revision=0,
),
execution=SimpleNamespace(instance_uuid='instance-test', placement_generation=1),
)
)
)
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
+18
View File
@@ -288,6 +288,24 @@ class TestUserInitEndpoint:
assert data['msg'] == 'ok'
assert data['data']['initialized'] is False
@pytest.mark.asyncio
async def test_account_info_exposes_instance_capabilities_not_first_account(self, quart_test_client, fake_api_app):
fake_api_app.user_service.is_initialized.return_value = True
fake_api_app.user_service.get_first_user = AsyncMock(
side_effect=AssertionError('public login bootstrap must not inspect an account')
)
response = await quart_test_client.get('/api/v1/user/account-info')
assert response.status_code == 200
data = await response.get_json()
assert data['data'] == {
'initialized': True,
'password_login_enabled': True,
'space_login_enabled': True,
}
fake_api_app.user_service.get_first_user.assert_not_awaited()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports:
@@ -0,0 +1,234 @@
"""Security tests for the LangBot-to-Space OAuth redirect boundary."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from urllib.parse import parse_qs, urlsplit
import pytest
import quart
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
pytestmark = pytest.mark.integration
WORKSPACE_UUID = '11111111-1111-4111-8111-111111111111'
@pytest.fixture
async def space_oauth_api():
account = SimpleNamespace(uuid='account-a', user='owner@example.com')
access = SimpleNamespace(
workspace=SimpleNamespace(uuid=WORKSPACE_UUID),
membership=SimpleNamespace(uuid='member-a', role='owner', projection_revision=1),
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
application = Mock()
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock(
side_effect=lambda purpose, **_: f'opaque-{purpose}-state'
)
local_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='local',
)
bound_account = SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='space',
)
application.user_service.consume_space_oauth_state = AsyncMock(
side_effect=lambda state, purpose: (
local_account if (state, purpose) == ('opaque-bind-state', 'bind') else None
)
)
application.user_service.bind_space_account = AsyncMock(return_value=bound_account)
application.user_service.generate_jwt_token = AsyncMock(return_value='rotated-account-token')
application.user_service.authenticate_space_user = AsyncMock(return_value=('space-login-token', bound_account))
application.user_service.verify_jwt_token = AsyncMock()
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(return_value=access)
application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
)
application.space_service.exchange_oauth_code = AsyncMock(
return_value={
'access_token': 'space-access-token',
'refresh_token': 'space-refresh-token',
'expires_in': 3600,
}
)
application.instance_config.data = {
'api': {'webui_url': 'http://localhost'},
'system': {'allow_modify_login_info': True},
}
quart_app = quart.Quart(__name__)
router = UserRouterGroup(application, quart_app)
await router.initialize()
return application, quart_app.test_client()
@pytest.mark.asyncio
async def test_public_login_state_is_server_issued(space_oauth_api):
application, client = space_oauth_api
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
authorize_url = (await response.get_json())['data']['authorize_url']
assert parse_qs(urlsplit(authorize_url).query)['state'] == ['opaque-login-state']
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
@pytest.mark.asyncio
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={
'redirect_uri': 'http://localhost/auth/space/callback',
'state': 'jwt.must-not-be-used',
},
headers={'Origin': 'http://localhost'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 1
application.space_service.get_oauth_authorize_url.assert_not_called()
@pytest.mark.asyncio
async def test_bind_state_is_account_bound_and_requires_authentication(space_oauth_api):
application, client = space_oauth_api
path = '/api/v1/user/space/bind-authorize-url'
query = {'redirect_uri': 'http://localhost/auth/space/callback?mode=bind'}
unauthorized = await client.get(path, query_string=query, headers={'Origin': 'http://localhost'})
response = await client.get(
path,
query_string=query,
headers={
'Origin': 'http://localhost',
'Authorization': 'Bearer user-token',
'X-Workspace-Id': WORKSPACE_UUID,
},
)
assert unauthorized.status_code == 401
assert response.status_code == 200
application.user_service.issue_space_oauth_state.assert_awaited_once_with('bind', account_uuid='account-a')
@pytest.mark.asyncio
async def test_redirect_origin_and_callback_path_are_restricted(space_oauth_api):
_, client = space_oauth_api
wrong_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'http://localhost'},
)
wrong_path = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost/arbitrary'},
headers={'Origin': 'http://localhost'},
)
forged_origin = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Origin': 'https://evil.example'},
)
forged_host = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://evil.example/auth/space/callback'},
headers={'Host': 'evil.example'},
)
assert (await wrong_origin.get_json())['code'] == 1
assert (await wrong_path.get_json())['code'] == 1
assert (await forged_origin.get_json())['code'] == 1
assert (await forged_host.get_json())['code'] == 1
@pytest.mark.asyncio
async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
headers={'Origin': 'https://irrelevant.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {
'webui_url': '',
'webhook_prefix': 'https://langbot.example/base/path',
}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
headers={'Host': 'attacker.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
response = await client.post(
'/api/v1/user/space/callback',
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
)
assert (await missing.get_json())['code'] == 1
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'space-login-token'
application.user_service.consume_space_oauth_state.assert_awaited_once_with('opaque-login-state', 'login')
application.space_service.exchange_oauth_code.assert_awaited_once_with('oauth-code')
@pytest.mark.asyncio
async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.user_service.consume_space_oauth_state.side_effect = [
ValueError('invalid state'),
SimpleNamespace(
uuid='account-a',
user='owner@example.com',
account_type='local',
),
]
rejected = await client.post(
'/api/v1/user/bind-space',
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
)
response = await client.post(
'/api/v1/user/bind-space',
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
)
assert rejected.status_code == 401
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
application.user_service.verify_jwt_token.assert_not_awaited()
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
+444
View File
@@ -0,0 +1,444 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
import pytest
import sqlalchemy
import jwt
from quart import Quart
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.api.http.controller.groups.workspaces import (
InvitationsRouterGroup,
WorkspacesRouterGroup,
)
from langbot.pkg.api.http.controller.groups.apikeys import ApiKeysRouterGroup
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.api.http.service.user import ControlPlaneDirectoryRequiredError, UserService
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import (
Workspace,
WorkspaceExecutionState,
WorkspaceInvitation,
WorkspaceMembership,
)
from langbot.pkg.workspace.collaboration import WorkspaceCollaborationService
from langbot.pkg.workspace.service import WorkspaceService
from langbot.pkg.workspace.policy import CloudWorkspacePolicy
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
class _PersistenceManager:
def __init__(self, engine):
self.engine = engine
def get_db_engine(self):
return self.engine
async def execute_async(self, *args, **kwargs):
async with self.engine.connect() as connection:
result = await connection.execute(*args, **kwargs)
await connection.commit()
return result
@staticmethod
def serialize_model(model, row, masked_columns=()):
return {
column.name: getattr(row, column.name)
for column in model.__table__.columns
if column.name not in masked_columns
}
@pytest.fixture
async def workspace_api(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-api.db"}')
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
application = SimpleNamespace()
application.persistence_mgr = _PersistenceManager(engine)
application.instance_config = SimpleNamespace(
data={
'system': {
'jwt': {'secret': 'workspace-api-secret', 'expire': 3600},
'allow_modify_login_info': True,
},
'api': {'global_api_key': ''},
}
)
application.logger = logging.getLogger('workspace-api-test')
application.workspace_service = WorkspaceService(
application,
instance_uuid='instance-workspace-api',
)
await application.workspace_service.ensure_singleton_workspace()
application.workspace_collaboration_service = WorkspaceCollaborationService(
application,
application.workspace_service,
)
application.user_service = UserService(application)
application.apikey_service = ApiKeyService(application)
owner = await application.user_service.create_initial_account(
'owner@example.com',
'owner-password',
)
owner_token = await application.user_service.generate_jwt_token(owner)
quart_app = Quart(__name__)
await WorkspacesRouterGroup(application, quart_app).initialize()
await InvitationsRouterGroup(application, quart_app).initialize()
await ApiKeysRouterGroup(application, quart_app).initialize()
await UserRouterGroup(application, quart_app).initialize()
yield application, quart_app.test_client(), engine, owner_token
await engine.dispose()
def _auth(token: str, workspace_uuid: str | None = None) -> dict[str, str]:
headers = {'Authorization': f'Bearer {token}'}
if workspace_uuid is not None:
headers['X-Workspace-Id'] = workspace_uuid
return headers
async def test_owner_invites_second_account_and_secret_is_not_persisted(workspace_api):
application, client, engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
workspace_uuid = current['workspace']['uuid']
assert current['membership']['role'] == 'owner'
assert 'member.invite' in current['permissions']
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(owner_token, workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'},
)
assert invite_response.status_code == 200
invite_data = (await invite_response.get_json())['data']
invitation_token = invite_data['token']
assert invitation_token.startswith('lbi_')
assert 'token_hash' not in invite_data['invitation']
async with engine.connect() as connection:
persisted_token_hash = await connection.scalar(
sqlalchemy.select(WorkspaceInvitation.token_hash).where(
WorkspaceInvitation.uuid == invite_data['invitation']['uuid']
)
)
assert persisted_token_hash is not None
assert persisted_token_hash != invitation_token
inspect_response = await client.post(
'/api/v1/invitations/inspect',
json={'token': invitation_token},
)
assert inspect_response.status_code == 200
inspected = (await inspect_response.get_json())['data']
assert inspected['workspace']['uuid'] == workspace_uuid
assert inspected['invitation']['normalized_email'] == 'member@example.com'
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {
'email': 'member@example.com',
'password': 'member-password',
},
},
)
assert accept_response.status_code == 200
member_auth = (await accept_response.get_json())['data']
assert member_auth['workspace_uuid'] == workspace_uuid
reused_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {
'email': 'member@example.com',
'password': 'member-password',
},
},
)
assert reused_response.status_code == 400
assert (await reused_response.get_json())['code'] == 'invitation_used'
member_current_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(member_auth['token'], workspace_uuid),
)
assert member_current_response.status_code == 200
member_current = (await member_current_response.get_json())['data']
assert member_current['membership']['role'] == 'viewer'
assert 'member.invite' not in member_current['permissions']
forbidden_invite = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(member_auth['token'], workspace_uuid),
json={'email': 'third@example.com', 'role': 'viewer'},
)
assert forbidden_invite.status_code == 403
assert (await forbidden_invite.get_json())['code'] == 'permission_denied'
async def test_workspace_selector_and_path_cannot_escape_membership(workspace_api):
_, client, _, owner_token = workspace_api
unknown_uuid = '00000000-0000-0000-0000-000000000099'
selector_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, unknown_uuid),
)
assert selector_response.status_code == 404
assert (await selector_response.get_json())['code'] == 'resource_not_found'
path_response = await client.get(
f'/api/v1/workspaces/{unknown_uuid}',
headers=_auth(owner_token),
)
assert path_response.status_code == 404
assert (await path_response.get_json())['code'] == 'resource_not_found'
async def test_oss_rejects_second_workspace(workspace_api):
_, client, _, owner_token = workspace_api
response = await client.post('/api/v1/workspaces', headers=_auth(owner_token), json={'name': 'Second'})
assert response.status_code == 403
assert (await response.get_json())['code'] == 'edition_limit'
async def test_jwt_uses_account_uuid_and_disabled_account_is_rejected(workspace_api):
_, client, engine, owner_token = workspace_api
payload = jwt.decode(
owner_token,
'workspace-api-secret',
algorithms=['HS256'],
audience='langbot-instance:instance-workspace-api',
issuer='langbot-core',
)
assert payload['sub']
assert payload['sub'] != payload['user']
async with engine.begin() as connection:
await connection.execute(sqlalchemy.update(User).where(User.uuid == payload['sub']).values(status='disabled'))
response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert response.status_code == 401
assert (await response.get_json())['code'] == 'invalid_authentication'
async def test_api_key_secret_is_one_time_and_viewer_cannot_manage_keys(workspace_api):
application, client, _engine, owner_token = workspace_api
current_response = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
workspace_uuid = (await current_response.get_json())['data']['workspace']['uuid']
create_response = await client.post(
'/api/v1/apikeys',
headers=_auth(owner_token, workspace_uuid),
json={'name': 'E2E automation', 'scopes': ['resource.view']},
)
assert create_response.status_code == 200
created = (await create_response.get_json())['data']['key']
assert created['key'].startswith('lbk_')
assert created['secret_available'] is True
assert 'key_hash' not in created
list_response = await client.get('/api/v1/apikeys', headers=_auth(owner_token, workspace_uuid))
listed = (await list_response.get_json())['data']['keys']
assert len(listed) == 1
assert 'key' not in listed[0]
assert 'key_hash' not in listed[0]
assert listed[0]['secret_available'] is False
identity = await application.apikey_service.authenticate_api_key(created['key'])
assert identity is not None
assert identity.workspace_uuid == workspace_uuid
assert identity.permissions == frozenset({'resource.view'})
invite_response = await client.post(
f'/api/v1/workspaces/{workspace_uuid}/invitations',
headers=_auth(owner_token, workspace_uuid),
json={'email': 'viewer@example.com', 'role': 'viewer'},
)
invitation_token = (await invite_response.get_json())['data']['token']
accept_response = await client.post(
'/api/v1/invitations/accept',
json={
'token': invitation_token,
'registration': {'email': 'viewer@example.com', 'password': 'viewer-password'},
},
)
viewer_token = (await accept_response.get_json())['data']['token']
forbidden = await client.post(
'/api/v1/apikeys',
headers=_auth(viewer_token, workspace_uuid),
json={'name': 'forbidden'},
)
assert forbidden.status_code == 403
assert (await forbidden.get_json())['code'] == 'permission_denied'
async def test_cloud_projection_is_selected_explicitly_and_directory_writes_use_control_plane(
workspace_api,
):
application, client, engine, owner_token = workspace_api
owner_uuid = jwt.decode(
owner_token,
'workspace-api-secret',
algorithms=['HS256'],
audience='langbot-instance:instance-workspace-api',
issuer='langbot-core',
)['sub']
cloud_workspace_uuid = '00000000-0000-0000-0000-000000000777'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid=cloud_workspace_uuid,
instance_uuid='instance-workspace-api',
name='Cloud Team',
slug='cloud-team',
type='team',
status='active',
source='cloud_projection',
projection_revision=12,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceExecutionState).values(
workspace_uuid=cloud_workspace_uuid,
instance_uuid='instance-workspace-api',
active_generation=12,
state='active',
write_fenced=False,
source='cloud',
desired_state_revision=12,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceMembership).values(
uuid='00000000-0000-0000-0000-000000000778',
workspace_uuid=cloud_workspace_uuid,
account_uuid=owner_uuid,
role='owner',
status='active',
projection_revision=12,
)
)
policy = CloudWorkspacePolicy()
application.workspace_service.policy = policy
application.workspace_collaboration_service.policy = policy
with pytest.raises(ControlPlaneDirectoryRequiredError):
await application.user_service.create_initial_account(
'forbidden-cloud-local@example.com',
'password',
)
omitted = await client.get('/api/v1/workspaces/current', headers=_auth(owner_token))
assert omitted.status_code == 404
refreshed_token = await client.get('/api/v1/user/check-token', headers=_auth(owner_token))
assert refreshed_token.status_code == 200
assert (await refreshed_token.get_json())['data']['token']
bootstrap_response = await client.get(
'/api/v1/workspaces/bootstrap',
headers=_auth(owner_token),
)
assert bootstrap_response.status_code == 200
bootstrap = (await bootstrap_response.get_json())['data']
singleton_uuid = (await application.workspace_service.get_singleton_workspace()).uuid
workspace_uuids = [item['workspace']['uuid'] for item in bootstrap['workspaces']]
assert set(workspace_uuids) == {singleton_uuid, cloud_workspace_uuid}
repeated = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
assert [item['workspace']['uuid'] for item in (await repeated.get_json())['data']['workspaces']] == workspace_uuids
by_uuid = {item['workspace']['uuid']: item for item in bootstrap['workspaces']}
assert by_uuid[singleton_uuid]['membership']['account_uuid'] == owner_uuid
assert by_uuid[singleton_uuid]['membership']['email'] == 'owner@example.com'
assert by_uuid[singleton_uuid]['permissions']
assert by_uuid[cloud_workspace_uuid]['placement_generation'] == 12
current_response = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, cloud_workspace_uuid),
)
assert current_response.status_code == 200
current = (await current_response.get_json())['data']
assert current['workspace']['uuid'] == cloud_workspace_uuid
assert current['workspace']['source'] == 'cloud_projection'
assert current['placement_generation'] == 12
create_workspace = await client.post(
'/api/v1/workspaces',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'name': 'Not in Core'},
)
assert create_workspace.status_code == 409
assert (await create_workspace.get_json())['code'] == 'control_plane_required'
create_invitation = await client.post(
f'/api/v1/workspaces/{cloud_workspace_uuid}/invitations',
headers=_auth(owner_token, cloud_workspace_uuid),
json={'email': 'member@example.com', 'role': 'viewer'},
)
assert create_invitation.status_code == 409
assert (await create_invitation.get_json())['code'] == 'control_plane_required'
async def test_account_bootstrap_does_not_disclose_non_member_workspaces(workspace_api):
application, client, engine, owner_token = workspace_api
foreign_workspace_uuid = '00000000-0000-0000-0000-000000000880'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.insert(Workspace).values(
uuid=foreign_workspace_uuid,
instance_uuid='instance-workspace-api',
name='Foreign Team',
slug='foreign-team',
type='team',
status='active',
source='cloud_projection',
projection_revision=1,
)
)
await connection.execute(
sqlalchemy.insert(WorkspaceExecutionState).values(
workspace_uuid=foreign_workspace_uuid,
instance_uuid='instance-workspace-api',
active_generation=1,
state='active',
write_fenced=False,
source='cloud',
desired_state_revision=1,
)
)
policy = CloudWorkspacePolicy()
application.workspace_service.policy = policy
application.workspace_collaboration_service.policy = policy
response = await client.get('/api/v1/workspaces/bootstrap', headers=_auth(owner_token))
assert response.status_code == 200
workspace_uuids = {item['workspace']['uuid'] for item in (await response.get_json())['data']['workspaces']}
assert foreign_workspace_uuid not in workspace_uuids
current = await client.get(
'/api/v1/workspaces/current',
headers=_auth(owner_token, foreign_workspace_uuid),
)
assert current.status_code == 404
assert (await current.get_json())['code'] == 'resource_not_found'