Files
LangBot/tests/integration/persistence/resource_migration_support.py
T
RockChinQ e1ac5e0fc8 feat(tenancy): add Workspace multi-tenant foundation (#2353)
* 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>
2026-07-30 21:43:35 +08:00

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))