mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
293 lines
13 KiB
Python
293 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import uuid
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from langbot.pkg.entity import persistence
|
|
from langbot.pkg.entity.persistence.base import Base
|
|
from langbot.pkg.persistence.alembic_runner import run_alembic_stamp, run_alembic_upgrade
|
|
from langbot.pkg.utils import importutil
|
|
|
|
from .resource_migration_support import TENANT_TABLES, create_legacy_resource_schema
|
|
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
|
|
|
|
|
async def _inspect(engine, callback):
|
|
async with engine.connect() as conn:
|
|
return await conn.run_sync(callback)
|
|
|
|
|
|
async def test_legacy_sqlite_resources_are_backfilled_and_contracted(tmp_path):
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-resources.db"}')
|
|
try:
|
|
await create_legacy_resource_schema(engine, instance_uuid='resource-migration-test')
|
|
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
await run_alembic_upgrade(engine, 'head')
|
|
|
|
async with engine.connect() as conn:
|
|
workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
|
assert workspace_uuid is not None
|
|
for table_name in TENANT_TABLES:
|
|
count, distinct_workspaces = (
|
|
await conn.execute(sa.text(f'SELECT COUNT(*), COUNT(DISTINCT workspace_uuid) FROM {table_name}'))
|
|
).one()
|
|
assert count == 1, table_name
|
|
assert distinct_workspaces == 1, table_name
|
|
assert (
|
|
await conn.scalar(
|
|
sa.text(f'SELECT COUNT(*) FROM {table_name} WHERE workspace_uuid != :workspace_uuid'),
|
|
{'workspace_uuid': workspace_uuid},
|
|
)
|
|
== 0
|
|
)
|
|
|
|
api_key = (
|
|
(await conn.execute(sa.text('SELECT key_hash, scopes, status, created_by_account_uuid FROM api_keys')))
|
|
.mappings()
|
|
.one()
|
|
)
|
|
assert api_key['key_hash'] == hashlib.sha256(b'lbk_legacy-secret').hexdigest()
|
|
stored_scopes = api_key['scopes']
|
|
if isinstance(stored_scopes, str):
|
|
stored_scopes = json.loads(stored_scopes)
|
|
assert stored_scopes == ['*']
|
|
assert api_key['status'] == 'active'
|
|
assert api_key['created_by_account_uuid'] is not None
|
|
assert await conn.scalar(sa.text('SELECT normalized_email FROM users')) == 'owner@example.com'
|
|
legacy_kb = (
|
|
(
|
|
await conn.execute(
|
|
sa.text(
|
|
'SELECT collection_id, legacy_vector_collection FROM knowledge_bases WHERE uuid = :uuid'
|
|
),
|
|
{'uuid': 'kb-1'},
|
|
)
|
|
)
|
|
.mappings()
|
|
.one()
|
|
)
|
|
assert legacy_kb['collection_id'] == 'collection-1'
|
|
assert legacy_kb['legacy_vector_collection'] == 1
|
|
assert (
|
|
await conn.scalar(
|
|
sa.text(
|
|
'SELECT COUNT(*) FROM metadata '
|
|
"WHERE key IN ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')"
|
|
)
|
|
)
|
|
== 0
|
|
)
|
|
assert (
|
|
await conn.scalar(
|
|
sa.text('SELECT COUNT(*) FROM workspace_metadata WHERE workspace_uuid = :workspace_uuid'),
|
|
{'workspace_uuid': workspace_uuid},
|
|
)
|
|
== 3
|
|
)
|
|
|
|
api_columns = await _inspect(
|
|
engine,
|
|
lambda conn: {column['name'] for column in sa.inspect(conn).get_columns('api_keys')},
|
|
)
|
|
assert 'key' not in api_columns
|
|
assert {'uuid', 'key_hash', 'scopes', 'status', 'expires_at', 'last_used_at'} <= api_columns
|
|
for table_name in TENANT_TABLES:
|
|
columns = await _inspect(
|
|
engine,
|
|
lambda conn, name=table_name: {column['name']: column for column in sa.inspect(conn).get_columns(name)},
|
|
)
|
|
assert columns['workspace_uuid']['nullable'] is False, table_name
|
|
if table_name == 'knowledge_bases':
|
|
assert columns['legacy_vector_collection']['nullable'] is False
|
|
|
|
pk_columns = {
|
|
table_name: tuple(
|
|
(
|
|
await _inspect(
|
|
engine,
|
|
lambda conn, name=table_name: sa.inspect(conn).get_pk_constraint(name),
|
|
)
|
|
)['constrained_columns']
|
|
)
|
|
for table_name in ('binary_storages', 'plugin_settings', 'monitoring_sessions')
|
|
}
|
|
assert pk_columns == {
|
|
'binary_storages': ('workspace_uuid', 'unique_key'),
|
|
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
|
|
'monitoring_sessions': ('workspace_uuid', 'session_id'),
|
|
}
|
|
|
|
pipeline_run_foreign_keys = await _inspect(
|
|
engine,
|
|
lambda conn: sa.inspect(conn).get_foreign_keys('pipeline_run_records'),
|
|
)
|
|
assert any(
|
|
tuple(foreign_key['constrained_columns']) == ('workspace_uuid', 'pipeline_uuid')
|
|
and foreign_key['referred_table'] == 'legacy_pipelines'
|
|
and tuple(foreign_key['referred_columns']) == ('workspace_uuid', 'uuid')
|
|
for foreign_key in pipeline_run_foreign_keys
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_legacy_vector_marker_backfill_resumes_from_nullable_expand_step(tmp_path):
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "legacy-vector-retry.db"}')
|
|
try:
|
|
await create_legacy_resource_schema(engine, instance_uuid='legacy-vector-retry')
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text('ALTER TABLE knowledge_bases ADD COLUMN legacy_vector_collection BOOLEAN NULL'))
|
|
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
await run_alembic_upgrade(engine, 'head')
|
|
|
|
async with engine.connect() as conn:
|
|
assert (
|
|
await conn.scalar(
|
|
sa.text('SELECT legacy_vector_collection FROM knowledge_bases WHERE uuid = :uuid'),
|
|
{'uuid': 'kb-1'},
|
|
)
|
|
== 1
|
|
)
|
|
columns = await _inspect(
|
|
engine,
|
|
lambda conn: {column['name']: column for column in sa.inspect(conn).get_columns('knowledge_bases')},
|
|
)
|
|
assert columns['legacy_vector_collection']['nullable'] is False
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_sqlite_scoped_keys_allow_cross_workspace_but_reject_same_workspace(tmp_path):
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "scoped-keys.db"}')
|
|
try:
|
|
await create_legacy_resource_schema(engine, instance_uuid='scoped-key-test')
|
|
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
|
|
await run_alembic_upgrade(engine, 'head')
|
|
|
|
second_workspace_uuid = str(uuid.uuid4())
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
|
|
first_workspace_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO workspaces '
|
|
'(uuid, instance_uuid, name, slug, type, status, source, projection_revision) '
|
|
"VALUES (:uuid, 'scoped-key-test', 'Second', 'second', 'team', 'active', "
|
|
"'cloud_projection', 0)"
|
|
),
|
|
{'uuid': second_workspace_uuid},
|
|
)
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO mcp_servers (uuid, workspace_uuid, name, enable, updated_at) '
|
|
"VALUES ('mcp-2', :workspace_uuid, 'shared-name', 1, CURRENT_TIMESTAMP)"
|
|
),
|
|
{'workspace_uuid': second_workspace_uuid},
|
|
)
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO plugin_settings '
|
|
'(workspace_uuid, plugin_author, plugin_name, enabled, '
|
|
'installation_uuid, artifact_digest, runtime_revision) '
|
|
"VALUES (:workspace_uuid, 'author', 'plugin', 1, "
|
|
':installation_uuid, :artifact_digest, 1)'
|
|
),
|
|
{
|
|
'workspace_uuid': second_workspace_uuid,
|
|
'installation_uuid': str(uuid.uuid4()),
|
|
'artifact_digest': hashlib.sha256(b'test-plugin-artifact').hexdigest(),
|
|
},
|
|
)
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO binary_storages '
|
|
'(workspace_uuid, unique_key, key, owner_type, owner) '
|
|
"VALUES (:workspace_uuid, 'plugin:demo:key', 'key', 'plugin', 'demo')"
|
|
),
|
|
{'workspace_uuid': second_workspace_uuid},
|
|
)
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO monitoring_sessions '
|
|
'(workspace_uuid, session_id, bot_id, last_activity, is_active) '
|
|
"VALUES (:workspace_uuid, 'session-1', 'bot-2', CURRENT_TIMESTAMP, 1)"
|
|
),
|
|
{'workspace_uuid': second_workspace_uuid},
|
|
)
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO mcp_servers (uuid, workspace_uuid, name, enable, updated_at) '
|
|
"VALUES ('mcp-duplicate', :workspace_uuid, 'shared-name', 1, CURRENT_TIMESTAMP)"
|
|
),
|
|
{'workspace_uuid': first_workspace_uuid},
|
|
)
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO llm_models (uuid, workspace_uuid, name, provider_uuid) '
|
|
"VALUES ('cross-workspace-model', :workspace_uuid, 'model', 'provider-1')"
|
|
),
|
|
{'workspace_uuid': second_workspace_uuid},
|
|
)
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with engine.begin() as conn:
|
|
await conn.execute(sa.text('PRAGMA foreign_keys=ON'))
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO pipeline_run_records '
|
|
'(uuid, workspace_uuid, pipeline_uuid, created_at) '
|
|
"VALUES ('cross-workspace-run', :workspace_uuid, 'pipeline-1', CURRENT_TIMESTAMP)"
|
|
),
|
|
{'workspace_uuid': second_workspace_uuid},
|
|
)
|
|
|
|
with pytest.raises(IntegrityError):
|
|
async with engine.begin() as conn:
|
|
await conn.execute(
|
|
sa.text(
|
|
'INSERT INTO mcp_servers (uuid, name, enable, updated_at) '
|
|
"VALUES ('unscoped-mcp', 'unscoped', 1, CURRENT_TIMESTAMP)"
|
|
)
|
|
)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_fresh_sqlite_schema_matches_resource_tenancy_contract(tmp_path):
|
|
importutil.import_modules_in_pkg(persistence)
|
|
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "fresh-resources.db"}')
|
|
try:
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await run_alembic_stamp(engine, '0001_baseline')
|
|
await run_alembic_upgrade(engine, 'head')
|
|
|
|
tables = await _inspect(engine, lambda conn: set(sa.inspect(conn).get_table_names()))
|
|
assert set(TENANT_TABLES) | {'workspace_metadata'} <= tables
|
|
for table_name in TENANT_TABLES:
|
|
columns = await _inspect(
|
|
engine,
|
|
lambda conn, name=table_name: {column['name']: column for column in sa.inspect(conn).get_columns(name)},
|
|
)
|
|
assert columns['workspace_uuid']['nullable'] is False, table_name
|
|
if table_name == 'knowledge_bases':
|
|
assert columns['legacy_vector_collection']['nullable'] is False
|
|
finally:
|
|
await engine.dispose()
|