mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-07 11:56:37 +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>
254 lines
9.9 KiB
Python
254 lines
9.9 KiB
Python
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
TENANT_TABLES = (
|
|
'api_keys',
|
|
'bots',
|
|
'bot_admins',
|
|
'binary_storages',
|
|
'mcp_servers',
|
|
'model_providers',
|
|
'llm_models',
|
|
'embedding_models',
|
|
'rerank_models',
|
|
'legacy_pipelines',
|
|
'pipeline_run_records',
|
|
'plugin_settings',
|
|
'knowledge_bases',
|
|
'knowledge_base_files',
|
|
'knowledge_base_chunks',
|
|
'webhooks',
|
|
'monitoring_messages',
|
|
'monitoring_llm_calls',
|
|
'monitoring_tool_calls',
|
|
'monitoring_sessions',
|
|
'monitoring_errors',
|
|
'monitoring_embedding_calls',
|
|
'monitoring_feedback',
|
|
)
|
|
|
|
|
|
def _uuid_table(metadata: sa.MetaData, name: str, *columns: sa.Column) -> sa.Table:
|
|
return sa.Table(name, metadata, sa.Column('uuid', sa.String(255), primary_key=True), *columns)
|
|
|
|
|
|
async def create_legacy_resource_schema(engine, *, instance_uuid: str) -> None:
|
|
"""Create the smallest representative pre-0010 schema with one row/table."""
|
|
metadata = sa.MetaData()
|
|
system_metadata = sa.Table(
|
|
'metadata',
|
|
metadata,
|
|
sa.Column('key', sa.String(255), primary_key=True),
|
|
sa.Column('value', sa.String(255)),
|
|
)
|
|
users = sa.Table(
|
|
'users',
|
|
metadata,
|
|
sa.Column('id', sa.Integer, primary_key=True),
|
|
sa.Column('user', sa.String(255), nullable=False),
|
|
sa.Column('password', sa.String(255), nullable=False),
|
|
)
|
|
api_keys = sa.Table(
|
|
'api_keys',
|
|
metadata,
|
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('key', sa.String(255), nullable=False, unique=True),
|
|
)
|
|
bots = _uuid_table(
|
|
metadata,
|
|
'bots',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('updated_at', sa.DateTime, nullable=False),
|
|
)
|
|
bot_admins = sa.Table(
|
|
'bot_admins',
|
|
metadata,
|
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
|
sa.Column('bot_uuid', sa.String(255), nullable=False),
|
|
sa.Column('launcher_type', sa.String(64), nullable=False),
|
|
sa.Column('launcher_id', sa.String(255), nullable=False),
|
|
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
|
|
)
|
|
binary_storages = sa.Table(
|
|
'binary_storages',
|
|
metadata,
|
|
sa.Column('unique_key', sa.String(255), primary_key=True),
|
|
sa.Column('key', sa.String(255), nullable=False),
|
|
sa.Column('owner_type', sa.String(255), nullable=False),
|
|
sa.Column('owner', sa.String(255), nullable=False),
|
|
)
|
|
mcp_servers = _uuid_table(
|
|
metadata,
|
|
'mcp_servers',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('enable', sa.Boolean, nullable=False),
|
|
sa.Column('updated_at', sa.DateTime, nullable=False),
|
|
)
|
|
model_providers = _uuid_table(
|
|
metadata,
|
|
'model_providers',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('requester', sa.String(255), nullable=False),
|
|
)
|
|
llm_models = _uuid_table(
|
|
metadata,
|
|
'llm_models',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
|
)
|
|
embedding_models = _uuid_table(
|
|
metadata,
|
|
'embedding_models',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
|
)
|
|
rerank_models = _uuid_table(
|
|
metadata,
|
|
'rerank_models',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('provider_uuid', sa.String(255), nullable=False),
|
|
)
|
|
legacy_pipelines = _uuid_table(
|
|
metadata,
|
|
'legacy_pipelines',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('is_default', sa.Boolean, nullable=False),
|
|
sa.Column('updated_at', sa.DateTime, nullable=False),
|
|
)
|
|
pipeline_run_records = _uuid_table(
|
|
metadata,
|
|
'pipeline_run_records',
|
|
sa.Column('pipeline_uuid', sa.String(255), nullable=False),
|
|
sa.Column('created_at', sa.DateTime, nullable=False),
|
|
)
|
|
plugin_settings = sa.Table(
|
|
'plugin_settings',
|
|
metadata,
|
|
sa.Column('plugin_author', sa.String(255), primary_key=True),
|
|
sa.Column('plugin_name', sa.String(255), primary_key=True),
|
|
sa.Column('enabled', sa.Boolean, nullable=False),
|
|
)
|
|
knowledge_bases = _uuid_table(
|
|
metadata,
|
|
'knowledge_bases',
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('collection_id', sa.String(255), nullable=True),
|
|
)
|
|
knowledge_base_files = _uuid_table(
|
|
metadata,
|
|
'knowledge_base_files',
|
|
sa.Column('kb_id', sa.String(255), nullable=True),
|
|
)
|
|
knowledge_base_chunks = _uuid_table(
|
|
metadata,
|
|
'knowledge_base_chunks',
|
|
sa.Column('file_id', sa.String(255), nullable=True),
|
|
)
|
|
webhooks = sa.Table(
|
|
'webhooks',
|
|
metadata,
|
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
|
sa.Column('name', sa.String(255), nullable=False),
|
|
sa.Column('enabled', sa.Boolean, nullable=False),
|
|
sa.Column('created_at', sa.DateTime, nullable=False),
|
|
)
|
|
|
|
monitoring_tables: dict[str, sa.Table] = {}
|
|
for table_name in (
|
|
'monitoring_messages',
|
|
'monitoring_llm_calls',
|
|
'monitoring_tool_calls',
|
|
'monitoring_errors',
|
|
'monitoring_embedding_calls',
|
|
):
|
|
monitoring_tables[table_name] = sa.Table(
|
|
table_name,
|
|
metadata,
|
|
sa.Column('id', sa.String(255), primary_key=True),
|
|
sa.Column('timestamp', sa.DateTime, nullable=False),
|
|
sa.Column('session_id', sa.String(255), nullable=True),
|
|
sa.Column('message_id', sa.String(255), nullable=True),
|
|
)
|
|
monitoring_tables['monitoring_sessions'] = sa.Table(
|
|
'monitoring_sessions',
|
|
metadata,
|
|
sa.Column('session_id', sa.String(255), primary_key=True),
|
|
sa.Column('bot_id', sa.String(255), nullable=False),
|
|
sa.Column('last_activity', sa.DateTime, nullable=False),
|
|
sa.Column('is_active', sa.Boolean, nullable=False),
|
|
)
|
|
monitoring_tables['monitoring_feedback'] = sa.Table(
|
|
'monitoring_feedback',
|
|
metadata,
|
|
sa.Column('id', sa.String(255), primary_key=True),
|
|
sa.Column('feedback_id', sa.String(255), nullable=False, unique=True),
|
|
sa.Column('timestamp', sa.DateTime, nullable=False),
|
|
sa.Column('session_id', sa.String(255), nullable=True),
|
|
sa.Column('message_id', sa.String(255), nullable=True),
|
|
)
|
|
|
|
now = datetime.datetime(2026, 1, 1)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(metadata.create_all)
|
|
await conn.execute(
|
|
system_metadata.insert(),
|
|
[
|
|
{'key': 'database_version', 'value': '25'},
|
|
{'key': 'instance_uuid', 'value': instance_uuid},
|
|
{'key': 'wizard_status', 'value': 'completed'},
|
|
{'key': 'wizard_progress', 'value': '3'},
|
|
{'key': 'rag_plugin_migration_needed', 'value': 'true'},
|
|
],
|
|
)
|
|
await conn.execute(users.insert().values(user='Owner@Example.COM', password='hash'))
|
|
await conn.execute(api_keys.insert().values(name='legacy', key='lbk_legacy-secret'))
|
|
await conn.execute(bots.insert().values(uuid='bot-1', name='bot', updated_at=now))
|
|
await conn.execute(bot_admins.insert().values(bot_uuid='bot-1', launcher_type='person', launcher_id='owner'))
|
|
await conn.execute(
|
|
binary_storages.insert().values(unique_key='plugin:demo:key', key='key', owner_type='plugin', owner='demo')
|
|
)
|
|
await conn.execute(mcp_servers.insert().values(uuid='mcp-1', name='shared-name', enable=True, updated_at=now))
|
|
await conn.execute(model_providers.insert().values(uuid='provider-1', name='provider', requester='openai'))
|
|
for table in (llm_models, embedding_models, rerank_models):
|
|
await conn.execute(table.insert().values(uuid=f'{table.name}-1', name='model', provider_uuid='provider-1'))
|
|
await conn.execute(
|
|
legacy_pipelines.insert().values(uuid='pipeline-1', name='pipeline', is_default=True, updated_at=now)
|
|
)
|
|
await conn.execute(
|
|
pipeline_run_records.insert().values(uuid='run-1', pipeline_uuid='pipeline-1', created_at=now)
|
|
)
|
|
await conn.execute(plugin_settings.insert().values(plugin_author='author', plugin_name='plugin', enabled=True))
|
|
await conn.execute(knowledge_bases.insert().values(uuid='kb-1', name='knowledge', collection_id='collection-1'))
|
|
await conn.execute(knowledge_base_files.insert().values(uuid='file-1', kb_id='kb-1'))
|
|
await conn.execute(knowledge_base_chunks.insert().values(uuid='chunk-1', file_id='file-1'))
|
|
await conn.execute(webhooks.insert().values(name='hook', enabled=True, created_at=now))
|
|
for table_name, table in monitoring_tables.items():
|
|
if table_name == 'monitoring_sessions':
|
|
values = {
|
|
'session_id': 'session-1',
|
|
'bot_id': 'bot-1',
|
|
'last_activity': now,
|
|
'is_active': True,
|
|
}
|
|
elif table_name == 'monitoring_feedback':
|
|
values = {
|
|
'id': 'feedback-row-1',
|
|
'feedback_id': 'feedback-1',
|
|
'timestamp': now,
|
|
'session_id': 'session-1',
|
|
'message_id': 'message-1',
|
|
}
|
|
else:
|
|
values = {
|
|
'id': f'{table_name}-1',
|
|
'timestamp': now,
|
|
'session_id': 'session-1',
|
|
'message_id': 'message-1',
|
|
}
|
|
await conn.execute(table.insert().values(**values))
|