mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
* 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 <[email protected]>
191 lines
8.3 KiB
Python
191 lines
8.3 KiB
Python
"""One-shot, operator-only Cloud PostgreSQL release migration entrypoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from collections.abc import Mapping
|
|
|
|
import sqlalchemy
|
|
|
|
from ..cloud.bootstrap import SUPPORTED_PGVECTOR_DIMENSIONS
|
|
from ..core import app as core_app
|
|
from ..core.stages.load_config import LoadConfigStage
|
|
from ..core.stages.setup_logger import SetupLoggerStage
|
|
from .mgr import PersistenceManager, PersistenceMode
|
|
from .postgresql_url import normalize_asyncpg_url
|
|
|
|
|
|
DEFAULT_OPERATOR_DSN_ENV = 'LANGBOT_CLOUD_MIGRATION_DSN'
|
|
_ENV_NAME = re.compile(r'^[A-Z_][A-Z0-9_]*$')
|
|
|
|
|
|
class CloudReleaseMigrationConfigurationError(RuntimeError):
|
|
"""Raised before any database operation when migration input is unsafe."""
|
|
|
|
|
|
def _url_endpoint(url: sqlalchemy.engine.URL, *, label: str) -> tuple[str, int]:
|
|
"""Return a comparison-safe PostgreSQL endpoint without leaking its DSN."""
|
|
|
|
try:
|
|
host = (url.host or '').strip().casefold()
|
|
port = url.port or 5432
|
|
except (TypeError, ValueError):
|
|
raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid') from None
|
|
if not host or isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535:
|
|
raise CloudReleaseMigrationConfigurationError(f'{label} PostgreSQL host or port is invalid')
|
|
return host, port
|
|
|
|
|
|
def _operator_database_url(
|
|
instance_config: dict,
|
|
*,
|
|
environ: Mapping[str, str],
|
|
) -> sqlalchemy.engine.URL:
|
|
database_config = instance_config.get('database')
|
|
if not isinstance(database_config, dict) or database_config.get('use') != 'postgresql':
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration requires explicit database.use=postgresql; SQLite fallback is forbidden'
|
|
)
|
|
|
|
runtime_config = database_config.get('postgresql')
|
|
if not isinstance(runtime_config, dict):
|
|
raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL configuration is missing')
|
|
|
|
migration_config = database_config.get('cloud_migration', {})
|
|
if not isinstance(migration_config, dict):
|
|
raise CloudReleaseMigrationConfigurationError('database.cloud_migration must be a mapping')
|
|
dsn_env = migration_config.get('operator_dsn_env', DEFAULT_OPERATOR_DSN_ENV)
|
|
if not isinstance(dsn_env, str) or not _ENV_NAME.fullmatch(dsn_env):
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'database.cloud_migration.operator_dsn_env must name an uppercase environment variable'
|
|
)
|
|
|
|
raw_dsn = environ.get(dsn_env, '').strip()
|
|
if not raw_dsn:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
f'Cloud release migration requires the operator DSN in environment variable {dsn_env}'
|
|
)
|
|
try:
|
|
operator_url = sqlalchemy.engine.make_url(raw_dsn)
|
|
except Exception:
|
|
# Never echo a malformed DSN because it may contain an unescaped secret.
|
|
raise CloudReleaseMigrationConfigurationError('Cloud release migration operator DSN is invalid') from None
|
|
|
|
try:
|
|
operator_url = normalize_asyncpg_url(operator_url)
|
|
except ValueError:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration operator DSN must use valid PostgreSQL asyncpg options'
|
|
) from None
|
|
|
|
operator_user = (operator_url.username or '').strip()
|
|
operator_database = (operator_url.database or '').strip()
|
|
operator_host, operator_port = _url_endpoint(operator_url, label='Cloud release migration operator')
|
|
runtime_url_value = runtime_config.get('url')
|
|
if runtime_url_value:
|
|
if not isinstance(runtime_url_value, str):
|
|
raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL must be a string')
|
|
try:
|
|
runtime_url = sqlalchemy.engine.make_url(runtime_url_value)
|
|
except Exception:
|
|
raise CloudReleaseMigrationConfigurationError('Cloud runtime PostgreSQL URL is invalid') from None
|
|
if runtime_url.drivername not in {'postgresql', 'postgresql+asyncpg'}:
|
|
raise CloudReleaseMigrationConfigurationError('Cloud runtime database URL must use PostgreSQL')
|
|
runtime_user = (runtime_url.username or '').strip()
|
|
runtime_database = (runtime_url.database or '').strip()
|
|
runtime_host, runtime_port = _url_endpoint(runtime_url, label='Cloud runtime')
|
|
else:
|
|
runtime_user = str(runtime_config.get('user', 'postgres') or '').strip()
|
|
runtime_database = str(runtime_config.get('database', 'postgres') or '').strip()
|
|
runtime_host = str(runtime_config.get('host', '') or '').strip().casefold()
|
|
runtime_port = runtime_config.get('port', 5432)
|
|
if not operator_user or not operator_database:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration operator DSN must include a user, host, and database'
|
|
)
|
|
if (
|
|
not runtime_user
|
|
or not runtime_database
|
|
or not runtime_host
|
|
or isinstance(runtime_port, bool)
|
|
or not isinstance(runtime_port, int)
|
|
or not 1 <= runtime_port <= 65535
|
|
):
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud runtime PostgreSQL user, host, port, and database are required'
|
|
)
|
|
if operator_user == runtime_user:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration requires a distinct operator role from the runtime PostgreSQL role'
|
|
)
|
|
if operator_database != runtime_database:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration operator DSN must target the configured runtime database'
|
|
)
|
|
if operator_host != runtime_host or operator_port != runtime_port:
|
|
# The first Cloud release intentionally requires the migrator and
|
|
# runtime to use the same PostgreSQL endpoint. Supporting a direct
|
|
# operator endpoint plus a runtime pooler requires a database-backed
|
|
# immutable cluster identity check; accepting aliases here would turn a
|
|
# same-named database on another cluster into a silent migration target.
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration operator DSN must target the configured runtime PostgreSQL endpoint'
|
|
)
|
|
|
|
vdb_config = instance_config.get('vdb')
|
|
if not isinstance(vdb_config, dict) or vdb_config.get('use') != 'pgvector':
|
|
raise CloudReleaseMigrationConfigurationError('Cloud release migration requires vdb.use=pgvector')
|
|
pgvector_config = vdb_config.get('pgvector')
|
|
if not isinstance(pgvector_config, dict) or pgvector_config.get('use_business_database') is not True:
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration requires vdb.pgvector.use_business_database=true'
|
|
)
|
|
dimensions = pgvector_config.get('allowed_dimensions')
|
|
if (
|
|
not isinstance(dimensions, list)
|
|
or not dimensions
|
|
or any(isinstance(item, bool) or not isinstance(item, int) for item in dimensions)
|
|
or not set(dimensions).issubset(SUPPORTED_PGVECTOR_DIMENSIONS)
|
|
):
|
|
raise CloudReleaseMigrationConfigurationError(
|
|
'Cloud release migration pgvector dimensions are outside the release-created index set'
|
|
)
|
|
|
|
return operator_url
|
|
|
|
|
|
async def run_cloud_release_migration(
|
|
ap: core_app.Application,
|
|
*,
|
|
environ: Mapping[str, str] | None = None,
|
|
) -> None:
|
|
"""Run and validate one release migration with an isolated operator DSN."""
|
|
|
|
operator_url = _operator_database_url(
|
|
ap.instance_config.data,
|
|
environ=os.environ if environ is None else environ,
|
|
)
|
|
manager = PersistenceManager(
|
|
ap,
|
|
mode=PersistenceMode.RELEASE_MIGRATION,
|
|
database_url=operator_url,
|
|
)
|
|
ap.persistence_mgr = manager
|
|
try:
|
|
await manager.initialize()
|
|
ap.logger.info('Cloud PostgreSQL release migration reached and validated the exact release head.')
|
|
finally:
|
|
await manager.shutdown()
|
|
|
|
|
|
async def run_cloud_release_migration_from_config(loop: asyncio.AbstractEventLoop) -> None:
|
|
"""Load only process configuration/logging, then run the one-shot job."""
|
|
|
|
ap = core_app.Application()
|
|
ap.event_loop = loop
|
|
await LoadConfigStage().run(ap)
|
|
await SetupLoggerStage().run(ap)
|
|
await run_cloud_release_migration(ap)
|