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>
This commit is contained in:
RockChinQ
2026-07-30 21:43:35 +08:00
committed by GitHub
parent 463b120923
commit e1ac5e0fc8
468 changed files with 78320 additions and 13137 deletions
@@ -0,0 +1,542 @@
"""add the workspace tenancy persistence kernel
Revision ID: 0009_workspace_tenancy
Revises: 0008_mcp_resource_prefs
Create Date: 2026-07-18
"""
from __future__ import annotations
import datetime
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0009_workspace_tenancy'
down_revision = '0008_mcp_resource_prefs'
branch_labels = None
depends_on = None
def _table_names(conn: sa.Connection) -> set[str]:
return set(sa.inspect(conn).get_table_names())
def _column_map(conn: sa.Connection, table_name: str) -> dict[str, dict]:
return {column['name']: column for column in sa.inspect(conn).get_columns(table_name)}
def _constraint_names(conn: sa.Connection, table_name: str) -> set[str]:
inspector = sa.inspect(conn)
names = {
constraint['name']
for constraint in inspector.get_check_constraints(table_name)
if constraint.get('name') is not None
}
names.update(
constraint['name']
for constraint in inspector.get_unique_constraints(table_name)
if constraint.get('name') is not None
)
return names
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
return {index['name'] for index in sa.inspect(conn).get_indexes(table_name)}
def _upgrade_users(conn: sa.Connection) -> None:
if 'users' not in _table_names(conn):
return
columns = _column_map(conn, 'users')
if 'uuid' not in columns:
op.add_column('users', sa.Column('uuid', sa.String(36), nullable=True))
if 'status' not in columns:
op.add_column('users', sa.Column('status', sa.String(32), nullable=True, server_default='active'))
if 'source' not in columns:
op.add_column('users', sa.Column('source', sa.String(32), nullable=True, server_default='local'))
if 'projection_revision' not in columns:
op.add_column(
'users',
sa.Column('projection_revision', sa.BigInteger(), nullable=True, server_default='0'),
)
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
sa.column('status', sa.String(32)),
sa.column('source', sa.String(32)),
sa.column('projection_revision', sa.BigInteger()),
)
seen_uuids: set[str] = set()
for user_id, account_uuid in conn.execute(sa.select(users.c.id, users.c.uuid).order_by(users.c.id)).all():
normalized_uuid = account_uuid.strip() if isinstance(account_uuid, str) else ''
try:
normalized_uuid = str(uuid.UUID(normalized_uuid))
except (ValueError, AttributeError):
normalized_uuid = ''
if not normalized_uuid or normalized_uuid in seen_uuids:
normalized_uuid = str(uuid.uuid4())
if normalized_uuid != account_uuid:
conn.execute(users.update().where(users.c.id == user_id).values(uuid=normalized_uuid))
seen_uuids.add(normalized_uuid)
conn.execute(users.update().where(users.c.status.is_(None)).values(status='active'))
conn.execute(users.update().where(users.c.source.is_(None)).values(source='local'))
conn.execute(users.update().where(users.c.projection_revision.is_(None)).values(projection_revision=0))
columns = _column_map(conn, 'users')
constraint_names = _constraint_names(conn, 'users')
needs_batch_alter = any(
columns[column_name]['nullable'] for column_name in ('uuid', 'status', 'source', 'projection_revision')
) or not {'ck_users_status', 'ck_users_source'}.issubset(constraint_names)
if needs_batch_alter:
with op.batch_alter_table('users') as batch_op:
if columns['uuid']['nullable']:
batch_op.alter_column('uuid', existing_type=sa.String(36), nullable=False)
if columns['status']['nullable']:
batch_op.alter_column(
'status',
existing_type=sa.String(32),
nullable=False,
server_default='active',
)
if columns['source']['nullable']:
batch_op.alter_column(
'source',
existing_type=sa.String(32),
nullable=False,
server_default='local',
)
if columns['projection_revision']['nullable']:
batch_op.alter_column(
'projection_revision',
existing_type=sa.BigInteger(),
nullable=False,
server_default='0',
)
if 'ck_users_status' not in constraint_names:
batch_op.create_check_constraint(
'ck_users_status',
"status IN ('active', 'disabled', 'deleted')",
)
if 'ck_users_source' not in constraint_names:
batch_op.create_check_constraint(
'ck_users_source',
"source IN ('local', 'cloud_projection')",
)
if 'uq_users_uuid' not in _index_names(conn, 'users'):
op.create_index('uq_users_uuid', 'users', ['uuid'], unique=True)
def _create_workspace_tables(conn: sa.Connection) -> None:
tables = _table_names(conn)
if 'users' not in tables:
# LangBot's supported startup path creates the baseline schema before
# Alembic runs. Keep direct Alembic probes on an empty database safe.
return
if 'workspaces' not in tables:
op.create_table(
'workspaces',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('slug', sa.String(255), nullable=False),
sa.Column('type', sa.String(32), nullable=False, server_default='team'),
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
sa.Column('created_by_account_uuid', sa.String(36), nullable=True),
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['created_by_account_uuid'],
['users.uuid'],
name='fk_workspaces_created_by_account',
ondelete='SET NULL',
),
sa.UniqueConstraint('instance_uuid', 'slug', name='uq_workspaces_instance_slug'),
sa.CheckConstraint("type IN ('personal', 'team')", name='ck_workspaces_type'),
sa.CheckConstraint(
"status IN ('provisioning', 'active', 'suspended', 'archived', 'deleted')",
name='ck_workspaces_status',
),
sa.CheckConstraint(
"source IN ('local', 'cloud_projection')",
name='ck_workspaces_source',
),
)
workspace_indexes = _index_names(conn, 'workspaces')
if 'ix_workspaces_instance_status' not in workspace_indexes:
op.create_index(
'ix_workspaces_instance_status',
'workspaces',
['instance_uuid', 'status'],
)
if 'uq_workspaces_local_instance' not in workspace_indexes:
op.create_index(
'uq_workspaces_local_instance',
'workspaces',
['instance_uuid'],
unique=True,
sqlite_where=sa.text("source = 'local'"),
postgresql_where=sa.text("source = 'local'"),
)
tables = _table_names(conn)
if 'workspace_memberships' not in tables:
op.create_table(
'workspace_memberships',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('account_uuid', sa.String(36), nullable=False),
sa.Column('role', sa.String(32), nullable=False),
sa.Column('status', sa.String(32), nullable=False, server_default='active'),
sa.Column('invited_by_account_uuid', sa.String(36), nullable=True),
sa.Column('joined_at', sa.DateTime(), nullable=True),
sa.Column('projection_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_memberships_workspace',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['account_uuid'],
['users.uuid'],
name='fk_workspace_memberships_account',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['invited_by_account_uuid'],
['users.uuid'],
name='fk_workspace_memberships_invited_by_account',
ondelete='SET NULL',
),
sa.UniqueConstraint(
'workspace_uuid',
'account_uuid',
name='uq_workspace_membership_account',
),
sa.CheckConstraint(
"role IN ('owner', 'admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_memberships_role',
),
sa.CheckConstraint(
"status IN ('active', 'disabled', 'removed')",
name='ck_workspace_memberships_status',
),
)
membership_indexes = _index_names(conn, 'workspace_memberships')
if 'ix_workspace_memberships_account_status' not in membership_indexes:
op.create_index(
'ix_workspace_memberships_account_status',
'workspace_memberships',
['account_uuid', 'status'],
)
tables = _table_names(conn)
if 'workspace_invitations' not in tables:
op.create_table(
'workspace_invitations',
sa.Column('uuid', sa.String(36), primary_key=True),
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('normalized_email', sa.String(320), nullable=False),
sa.Column('role', sa.String(32), nullable=False),
sa.Column('token_hash', sa.String(255), nullable=False),
sa.Column('status', sa.String(32), nullable=False, server_default='pending'),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('accepted_at', sa.DateTime(), nullable=True),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('created_by_account_uuid', sa.String(36), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_invitations_workspace',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['created_by_account_uuid'],
['users.uuid'],
name='fk_workspace_invitations_created_by_account',
ondelete='CASCADE',
),
sa.CheckConstraint(
"role IN ('admin', 'developer', 'operator', 'viewer')",
name='ck_workspace_invitations_role',
),
sa.CheckConstraint(
"status IN ('pending', 'accepted', 'revoked', 'expired')",
name='ck_workspace_invitations_status',
),
)
invitation_indexes = _index_names(conn, 'workspace_invitations')
if 'uq_workspace_invitations_token_hash' not in invitation_indexes:
op.create_index(
'uq_workspace_invitations_token_hash',
'workspace_invitations',
['token_hash'],
unique=True,
)
if 'uq_workspace_invitations_pending_email' not in invitation_indexes:
op.create_index(
'uq_workspace_invitations_pending_email',
'workspace_invitations',
['workspace_uuid', 'normalized_email'],
unique=True,
sqlite_where=sa.text("status = 'pending'"),
postgresql_where=sa.text("status = 'pending'"),
)
tables = _table_names(conn)
if 'workspace_execution_states' not in tables:
op.create_table(
'workspace_execution_states',
sa.Column('workspace_uuid', sa.String(36), primary_key=True),
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('active_generation', sa.BigInteger(), nullable=False, server_default='1'),
sa.Column('state', sa.String(32), nullable=False, server_default='active'),
sa.Column('write_fenced', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('source', sa.String(32), nullable=False, server_default='local'),
sa.Column('desired_state_revision', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_execution_states_workspace',
ondelete='CASCADE',
),
sa.CheckConstraint('active_generation > 0', name='ck_workspace_execution_generation'),
sa.CheckConstraint(
"state IN ('provisioning', 'active', 'migrating', 'draining', 'inactive')",
name='ck_workspace_execution_state',
),
sa.CheckConstraint(
"source IN ('local', 'cloud')",
name='ck_workspace_execution_source',
),
)
execution_indexes = _index_names(conn, 'workspace_execution_states')
if 'ix_workspace_execution_states_instance_state' not in execution_indexes:
op.create_index(
'ix_workspace_execution_states_instance_state',
'workspace_execution_states',
['instance_uuid', 'state'],
)
def _load_instance_uuid(conn: sa.Connection) -> str | None:
if 'metadata' not in _table_names(conn):
return None
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')).scalar_one_or_none()
if not isinstance(value, str) or not value.strip():
return None
return value.strip()
def _bootstrap_default_workspace(conn: sa.Connection) -> None:
required_tables = {'users', 'workspaces', 'workspace_memberships', 'workspace_execution_states'}
if not required_tables.issubset(_table_names(conn)):
return
instance_uuid = _load_instance_uuid(conn)
users_exist = 'users' in _table_names(conn) and bool(conn.execute(sa.text('SELECT 1 FROM users LIMIT 1')).first())
if instance_uuid is None:
if users_exist:
raise RuntimeError("Cannot bootstrap the default workspace without metadata['instance_uuid']")
return
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('name', sa.String(255)),
sa.column('slug', sa.String(255)),
sa.column('type', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
sa.column('source', sa.String(32)),
sa.column('projection_revision', sa.BigInteger()),
)
local_rows = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid,
workspaces.c.source == 'local',
)
).all()
if len(local_rows) > 1:
raise RuntimeError(f'Multiple local workspaces already exist for instance {instance_uuid!r}')
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
)
owner_account_uuid = None
if 'users' in _table_names(conn):
owner_account_uuid = conn.execute(sa.select(users.c.uuid).order_by(users.c.id).limit(1)).scalar_one_or_none()
if local_rows:
workspace_uuid = local_rows[0][0]
if owner_account_uuid is not None:
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.where(workspaces.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=owner_account_uuid)
)
else:
workspace_uuid = str(uuid.uuid4())
conn.execute(
workspaces.insert().values(
uuid=workspace_uuid,
instance_uuid=instance_uuid,
name='Default Workspace',
slug='default',
type='team',
status='active',
created_by_account_uuid=owner_account_uuid,
source='local',
projection_revision=0,
)
)
execution_states = sa.table(
'workspace_execution_states',
sa.column('workspace_uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('active_generation', sa.BigInteger()),
sa.column('state', sa.String(32)),
sa.column('write_fenced', sa.Boolean()),
sa.column('source', sa.String(32)),
sa.column('desired_state_revision', sa.BigInteger()),
)
execution_state = conn.execute(
sa.select(
execution_states.c.instance_uuid,
execution_states.c.active_generation,
execution_states.c.state,
execution_states.c.write_fenced,
execution_states.c.source,
).where(execution_states.c.workspace_uuid == workspace_uuid)
).first()
if execution_state is None:
conn.execute(
execution_states.insert().values(
workspace_uuid=workspace_uuid,
instance_uuid=instance_uuid,
active_generation=1,
state='active',
write_fenced=False,
source='local',
desired_state_revision=0,
)
)
elif (
execution_state.instance_uuid != instance_uuid
or execution_state.active_generation != 1
or execution_state.state != 'active'
or execution_state.write_fenced
or execution_state.source != 'local'
):
raise RuntimeError(f'Default workspace {workspace_uuid!r} has an invalid local execution state')
if owner_account_uuid is None:
return
memberships = sa.table(
'workspace_memberships',
sa.column('uuid', sa.String(36)),
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('joined_at', sa.DateTime()),
sa.column('projection_revision', sa.BigInteger()),
)
membership = conn.execute(
sa.select(memberships.c.uuid, memberships.c.joined_at).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.account_uuid == owner_account_uuid,
)
).first()
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
if membership is None:
conn.execute(
memberships.insert().values(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_uuid,
account_uuid=owner_account_uuid,
role='owner',
status='active',
joined_at=now,
projection_revision=0,
)
)
else:
conn.execute(
memberships.update()
.where(memberships.c.uuid == membership.uuid)
.values(
role='owner',
status='active',
joined_at=membership.joined_at or now,
)
)
def upgrade() -> None:
conn = op.get_bind()
_upgrade_users(conn)
_create_workspace_tables(conn)
_bootstrap_default_workspace(conn)
def downgrade() -> None:
conn = op.get_bind()
tables = _table_names(conn)
for table_name in (
'workspace_execution_states',
'workspace_invitations',
'workspace_memberships',
'workspaces',
):
if table_name in tables:
op.drop_table(table_name)
if 'users' not in _table_names(conn):
return
indexes = _index_names(conn, 'users')
if 'uq_users_uuid' in indexes:
op.drop_index('uq_users_uuid', table_name='users')
columns = _column_map(conn, 'users')
constraint_names = _constraint_names(conn, 'users')
with op.batch_alter_table('users') as batch_op:
# SQLite batch recreation otherwise preserves the named checks while
# dropping their referenced columns, producing ``no such column`` only
# after the Workspace directory tables have already been removed.
for constraint_name in ('ck_users_source', 'ck_users_status'):
if constraint_name in constraint_names:
batch_op.drop_constraint(constraint_name, type_='check')
for column_name in ('projection_revision', 'source', 'status', 'uuid'):
if column_name in columns:
batch_op.drop_column(column_name)
@@ -0,0 +1,884 @@
"""scope every tenant-owned resource to a workspace
Revision ID: 0010_scope_resources
Revises: 0009_workspace_tenancy
Create Date: 2026-07-19
This migration is intentionally expand/backfill/contract. Existing rows are
bound to the single local Workspace created by revision 0009 before any
non-null or scoped-key constraint is installed.
"""
from __future__ import annotations
import hashlib
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0010_scope_resources'
down_revision = '0009_workspace_tenancy'
branch_labels = None
depends_on = None
_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',
)
_COMPOSITE_PRIMARY_KEYS = {
'binary_storages': ('workspace_uuid', 'unique_key'),
'plugin_settings': ('workspace_uuid', 'plugin_author', 'plugin_name'),
'monitoring_sessions': ('workspace_uuid', 'session_id'),
}
_COMPOSITE_FOREIGN_KEYS = {
'bot_admins': (
(
'fk_bot_admins_workspace_bot',
('workspace_uuid', 'bot_uuid'),
'bots',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'llm_models': (
(
'fk_llm_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'embedding_models': (
(
'fk_embedding_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'rerank_models': (
(
'fk_rerank_models_workspace_provider',
('workspace_uuid', 'provider_uuid'),
'model_providers',
('workspace_uuid', 'uuid'),
None,
),
),
'pipeline_run_records': (
(
'fk_pipeline_run_records_workspace_pipeline',
('workspace_uuid', 'pipeline_uuid'),
'legacy_pipelines',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'knowledge_base_files': (
(
'fk_knowledge_base_files_workspace_kb',
('workspace_uuid', 'kb_id'),
'knowledge_bases',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
'knowledge_base_chunks': (
(
'fk_knowledge_base_chunks_workspace_file',
('workspace_uuid', 'file_id'),
'knowledge_base_files',
('workspace_uuid', 'uuid'),
'CASCADE',
),
),
}
_SCOPED_INDEXES: dict[str, tuple[tuple[str, tuple[str, ...], bool, sa.TextClause | None], ...]] = {
'api_keys': (
('uq_api_keys_uuid', ('uuid',), True, None),
('uq_api_keys_key_hash', ('key_hash',), True, None),
('ix_api_keys_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_api_keys_workspace_status', ('workspace_uuid', 'status'), False, None),
),
'bots': (
('uq_bots_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_bots_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_bots_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'bot_admins': (
(
'uq_bot_admin',
('workspace_uuid', 'bot_uuid', 'launcher_type', 'launcher_id'),
True,
None,
),
('ix_bot_admins_workspace_bot', ('workspace_uuid', 'bot_uuid'), False, None),
),
'binary_storages': (
(
'ix_binary_storages_workspace_owner',
('workspace_uuid', 'owner_type', 'owner'),
False,
None,
),
),
'mcp_servers': (
('uq_mcp_servers_workspace_name', ('workspace_uuid', 'name'), True, None),
('ix_mcp_servers_workspace_enable', ('workspace_uuid', 'enable'), False, None),
('ix_mcp_servers_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'model_providers': (
('uq_model_providers_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_model_providers_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_model_providers_workspace_requester', ('workspace_uuid', 'requester'), False, None),
),
'llm_models': (
('ix_llm_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_llm_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'embedding_models': (
('ix_embedding_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_embedding_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'rerank_models': (
('ix_rerank_models_workspace_provider', ('workspace_uuid', 'provider_uuid'), False, None),
('ix_rerank_models_workspace_name', ('workspace_uuid', 'name'), False, None),
),
'legacy_pipelines': (
('uq_legacy_pipelines_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_legacy_pipelines_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_legacy_pipelines_workspace_default', ('workspace_uuid', 'is_default'), False, None),
('ix_legacy_pipelines_workspace_updated', ('workspace_uuid', 'updated_at'), False, None),
),
'pipeline_run_records': (
(
'ix_pipeline_run_records_workspace_pipeline',
('workspace_uuid', 'pipeline_uuid'),
False,
None,
),
(
'ix_pipeline_run_records_workspace_created',
('workspace_uuid', 'created_at'),
False,
None,
),
),
'plugin_settings': (('ix_plugin_settings_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),),
'knowledge_bases': (
('uq_knowledge_bases_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_knowledge_bases_workspace_name', ('workspace_uuid', 'name'), False, None),
(
'uq_knowledge_bases_workspace_collection',
('workspace_uuid', 'collection_id'),
True,
sa.text('collection_id IS NOT NULL'),
),
),
'knowledge_base_files': (
('uq_knowledge_base_files_workspace_uuid', ('workspace_uuid', 'uuid'), True, None),
('ix_knowledge_base_files_workspace_kb', ('workspace_uuid', 'kb_id'), False, None),
),
'knowledge_base_chunks': (('ix_knowledge_base_chunks_workspace_file', ('workspace_uuid', 'file_id'), False, None),),
'webhooks': (
('ix_webhooks_workspace_name', ('workspace_uuid', 'name'), False, None),
('ix_webhooks_workspace_enabled', ('workspace_uuid', 'enabled'), False, None),
('ix_webhooks_workspace_created', ('workspace_uuid', 'created_at'), False, None),
),
'monitoring_messages': (
('ix_monitoring_messages_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_messages_workspace_bot', ('workspace_uuid', 'bot_id', 'timestamp'), False, None),
(
'ix_monitoring_messages_workspace_pipeline',
('workspace_uuid', 'pipeline_id', 'timestamp'),
False,
None,
),
('ix_monitoring_messages_workspace_session', ('workspace_uuid', 'session_id'), False, None),
),
'monitoring_llm_calls': (
('ix_monitoring_llm_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_llm_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_llm_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_tool_calls': (
('ix_monitoring_tool_calls_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_tool_calls_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_tool_calls_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_sessions': (
('ix_monitoring_sessions_workspace_activity', ('workspace_uuid', 'last_activity'), False, None),
('ix_monitoring_sessions_workspace_active', ('workspace_uuid', 'is_active'), False, None),
('ix_monitoring_sessions_workspace_bot', ('workspace_uuid', 'bot_id', 'last_activity'), False, None),
),
'monitoring_errors': (
('ix_monitoring_errors_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_errors_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_errors_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
'monitoring_embedding_calls': (
(
'ix_monitoring_embedding_calls_workspace_timestamp',
('workspace_uuid', 'timestamp'),
False,
None,
),
(
'ix_monitoring_embedding_calls_workspace_kb',
('workspace_uuid', 'knowledge_base_id'),
False,
None,
),
(
'ix_monitoring_embedding_calls_workspace_session',
('workspace_uuid', 'session_id'),
False,
None,
),
),
'monitoring_feedback': (
(
'uq_monitoring_feedback_workspace_feedback_id',
('workspace_uuid', 'feedback_id'),
True,
None,
),
('ix_monitoring_feedback_workspace_timestamp', ('workspace_uuid', 'timestamp'), False, None),
('ix_monitoring_feedback_workspace_session', ('workspace_uuid', 'session_id'), False, None),
('ix_monitoring_feedback_workspace_message', ('workspace_uuid', 'message_id'), False, None),
),
}
def _inspector(conn: sa.Connection) -> sa.Inspector:
return sa.inspect(conn)
def _table_names(conn: sa.Connection) -> set[str]:
return set(_inspector(conn).get_table_names())
def _columns(conn: sa.Connection, table_name: str) -> dict[str, dict]:
return {column['name']: column for column in _inspector(conn).get_columns(table_name)}
def _index_names(conn: sa.Connection, table_name: str) -> set[str]:
return {index['name'] for index in _inspector(conn).get_indexes(table_name)}
def _unique_column_sets(conn: sa.Connection, table_name: str) -> set[tuple[str, ...]]:
inspector = _inspector(conn)
result = {
tuple(constraint.get('column_names') or ()) for constraint in inspector.get_unique_constraints(table_name)
}
result.update(
tuple(index.get('column_names') or ()) for index in inspector.get_indexes(table_name) if index.get('unique')
)
return result
def _foreign_key_exists(
conn: sa.Connection,
table_name: str,
local_columns: tuple[str, ...],
referred_table: str,
referred_columns: tuple[str, ...],
) -> bool:
return any(
tuple(foreign_key.get('constrained_columns') or ()) == local_columns
and foreign_key.get('referred_table') == referred_table
and tuple(foreign_key.get('referred_columns') or ()) == referred_columns
for foreign_key in _inspector(conn).get_foreign_keys(table_name)
)
def _metadata_value(conn: sa.Connection, key: str) -> str | None:
if 'metadata' not in _table_names(conn):
return None
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
value = conn.execute(sa.select(metadata.c.value).where(metadata.c.key == key)).scalar_one_or_none()
return value.strip() if isinstance(value, str) and value.strip() else None
def _default_workspace_uuid(conn: sa.Connection) -> str | None:
if 'workspaces' not in _table_names(conn):
return None
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('source', sa.String(32)),
)
instance_uuid = _metadata_value(conn, 'instance_uuid')
query = sa.select(workspaces.c.uuid).where(workspaces.c.source == 'local')
if instance_uuid is not None:
query = query.where(workspaces.c.instance_uuid == instance_uuid)
rows = conn.execute(query).all()
if len(rows) > 1:
raise RuntimeError('Cannot backfill tenant resources: multiple local Workspaces exist')
return rows[0][0] if rows else None
def _upgrade_normalized_email(conn: sa.Connection) -> None:
if 'users' not in _table_names(conn):
return
columns = _columns(conn, 'users')
if 'normalized_email' not in columns:
op.add_column('users', sa.Column('normalized_email', sa.String(320), nullable=True))
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('user', sa.String(255)),
sa.column('normalized_email', sa.String(320)),
)
# Use the exact same normalization algorithm as the runtime. Database
# ``lower()`` is ASCII-only on SQLite and is not equivalent to Python
# ``casefold()`` (for example, Straße -> strasse). Recompute every row so
# an interrupted expand/backfill attempt using an older migration body can
# be resumed safely.
seen_emails: dict[str, int] = {}
for user_id, email in conn.execute(sa.select(users.c.id, users.c.user).order_by(users.c.id)).all():
normalized_email = str(email or '').strip().casefold()
if not normalized_email:
raise RuntimeError(f'Cannot normalize empty account identity for user row {user_id}')
if len(normalized_email) > 320:
raise RuntimeError(
f'Cannot normalize account identity for user row {user_id}: canonical value exceeds 320 characters'
)
duplicate_user_id = seen_emails.get(normalized_email)
if duplicate_user_id is not None:
raise RuntimeError(
f'Cannot create normalized account identity: user rows '
f'{duplicate_user_id} and {user_id} both normalize to {normalized_email!r}'
)
seen_emails[normalized_email] = user_id
conn.execute(users.update().where(users.c.id == user_id).values(normalized_email=normalized_email))
columns = _columns(conn, 'users')
checks = {
constraint.get('name'): constraint
for constraint in _inspector(conn).get_check_constraints('users')
if constraint.get('name') is not None
}
identity_check = checks.get('ck_users_normalized_email')
identity_check_sql = str((identity_check or {}).get('sqltext') or '').casefold()
# Python casefold is the canonical identity algorithm. SQL ``lower`` is
# dialect/locale dependent (notably Cherokee folds to uppercase in Python
# but PostgreSQL lowercases it), so the database validates only portable
# structural invariants and uniqueness.
replace_legacy_identity_check = identity_check is not None and 'lower' in identity_check_sql
needs_contract = columns['normalized_email']['nullable'] or identity_check is None or replace_legacy_identity_check
if needs_contract:
with op.batch_alter_table('users') as batch_op:
if replace_legacy_identity_check:
batch_op.drop_constraint('ck_users_normalized_email', type_='check')
if columns['normalized_email']['nullable']:
batch_op.alter_column(
'normalized_email',
existing_type=columns['normalized_email']['type'],
nullable=False,
)
if identity_check is None or replace_legacy_identity_check:
batch_op.create_check_constraint(
'ck_users_normalized_email',
'normalized_email = trim(normalized_email) '
'AND length(normalized_email) > 0 '
'AND length(normalized_email) <= 320',
)
if 'uq_users_normalized_email' not in _index_names(conn, 'users'):
op.create_index('uq_users_normalized_email', 'users', ['normalized_email'], unique=True)
def _api_key_owner(conn: sa.Connection, workspace_uuid: str | None) -> str | None:
if workspace_uuid is None or 'workspace_memberships' not in _table_names(conn):
return None
memberships = sa.table(
'workspace_memberships',
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
)
return conn.execute(
sa.select(memberships.c.account_uuid)
.where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.role == 'owner',
)
.limit(1)
).scalar_one_or_none()
def _expand_and_hash_api_keys(conn: sa.Connection, workspace_uuid: str | None) -> None:
if 'api_keys' not in _table_names(conn):
return
columns = _columns(conn, 'api_keys')
additions = (
('uuid', sa.Column('uuid', sa.String(36), nullable=True)),
('created_by_account_uuid', sa.Column('created_by_account_uuid', sa.String(36), nullable=True)),
('key_hash', sa.Column('key_hash', sa.String(64), nullable=True)),
('scopes', sa.Column('scopes', sa.JSON(), nullable=True)),
('status', sa.Column('status', sa.String(32), nullable=True, server_default='active')),
('expires_at', sa.Column('expires_at', sa.DateTime(), nullable=True)),
('last_used_at', sa.Column('last_used_at', sa.DateTime(), nullable=True)),
)
for name, column in additions:
if name not in columns:
op.add_column('api_keys', column)
columns = _columns(conn, 'api_keys')
api_key_columns = [sa.column('id', sa.Integer())]
for name in ('uuid', 'created_by_account_uuid', 'key_hash', 'scopes', 'status', 'key'):
if name in columns:
api_key_columns.append(sa.column(name, columns[name]['type']))
api_keys = sa.table('api_keys', *api_key_columns)
owner_uuid = _api_key_owner(conn, workspace_uuid)
selected_columns = [api_keys.c.id, api_keys.c.uuid, api_keys.c.key_hash]
if 'key' in api_keys.c:
selected_columns.append(api_keys.c.key)
rows = conn.execute(sa.select(*selected_columns).order_by(api_keys.c.id)).mappings().all()
for row in rows:
values: dict[str, object] = {}
if not row['uuid']:
values['uuid'] = str(uuid.uuid4())
if not row['key_hash']:
plaintext = row.get('key')
if not isinstance(plaintext, str) or not plaintext:
raise RuntimeError(f'API key row {row["id"]} has no secret to hash')
values['key_hash'] = hashlib.sha256(plaintext.encode()).hexdigest()
if values:
conn.execute(api_keys.update().where(api_keys.c.id == row['id']).values(**values))
# Pre-tenancy API keys historically had unrestricted instance access. A
# wildcard preserves that behavior while binding it to the backfilled
# Workspace; new keys must store their requested explicit scopes.
conn.execute(api_keys.update().where(api_keys.c.scopes.is_(None)).values(scopes=['*']))
conn.execute(api_keys.update().where(api_keys.c.status.is_(None)).values(status='active'))
if owner_uuid is not None:
conn.execute(
api_keys.update()
.where(api_keys.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=owner_uuid)
)
columns = _columns(conn, 'api_keys')
check_names = {constraint.get('name') for constraint in _inspector(conn).get_check_constraints('api_keys')}
existing_fks = _inspector(conn).get_foreign_keys('api_keys')
creator_fk_exists = any(
tuple(foreign_key.get('constrained_columns') or ()) == ('created_by_account_uuid',)
and foreign_key.get('referred_table') == 'users'
and tuple(foreign_key.get('referred_columns') or ()) == ('uuid',)
for foreign_key in existing_fks
)
has_legacy_key = 'key' in columns
needs_contract = (
any(columns[name]['nullable'] for name in ('uuid', 'key_hash', 'scopes', 'status'))
or 'ck_api_keys_status' not in check_names
or not creator_fk_exists
or has_legacy_key
)
if needs_contract:
naming = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
with op.batch_alter_table('api_keys', naming_convention=naming) as batch_op:
for name in ('uuid', 'key_hash', 'scopes', 'status'):
if columns[name]['nullable']:
batch_op.alter_column(name, existing_type=columns[name]['type'], nullable=False)
if 'ck_api_keys_status' not in check_names:
batch_op.create_check_constraint('ck_api_keys_status', "status IN ('active', 'revoked')")
if not creator_fk_exists:
batch_op.create_foreign_key(
'fk_api_keys_created_by_account',
'users',
['created_by_account_uuid'],
['uuid'],
ondelete='SET NULL',
)
if has_legacy_key:
# Dropping the column also removes its old global plaintext
# unique constraint/index during SQLite's batch rebuild.
batch_op.drop_column('key')
def _expand_workspace_columns(conn: sa.Connection, workspace_uuid: str | None) -> None:
tables = _table_names(conn)
for table_name in _TENANT_TABLES:
if table_name not in tables:
continue
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns:
op.add_column(table_name, sa.Column('workspace_uuid', sa.String(36), nullable=True))
tenant_table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
null_count = conn.scalar(
sa.select(sa.func.count()).select_from(tenant_table).where(tenant_table.c.workspace_uuid.is_(None))
)
if null_count:
if workspace_uuid is None:
raise RuntimeError(f'Cannot backfill {table_name}: the instance has no unique local Workspace')
conn.execute(
tenant_table.update()
.where(tenant_table.c.workspace_uuid.is_(None))
.values(workspace_uuid=workspace_uuid)
)
def _mark_legacy_vector_collections(conn: sa.Connection, workspace_uuid: str | None) -> None:
"""Persist which pre-tenancy KBs must keep using ``collection_id``.
The marker is backfilled only when this migration introduces the column,
or resumes while that newly added column is still nullable. A fresh
schema already contains the non-null column with ``false`` as its default,
so knowledge bases created under the scoped-vector contract can never be
mistaken for legacy data during a later migration retry.
"""
if 'knowledge_bases' not in _table_names(conn):
return
columns = _columns(conn, 'knowledge_bases')
introduced = 'legacy_vector_collection' not in columns
if introduced:
op.add_column(
'knowledge_bases',
sa.Column('legacy_vector_collection', sa.Boolean(), nullable=True),
)
columns = _columns(conn, 'knowledge_bases')
needs_legacy_backfill = introduced or columns['legacy_vector_collection']['nullable']
knowledge_bases = sa.table(
'knowledge_bases',
sa.column('collection_id', columns['collection_id']['type']),
sa.column('legacy_vector_collection', sa.Boolean()),
*((sa.column('workspace_uuid', columns['workspace_uuid']['type']),) if 'workspace_uuid' in columns else ()),
)
if needs_legacy_backfill and workspace_uuid is not None:
legacy_filter = sa.and_(
knowledge_bases.c.collection_id.is_not(None),
sa.func.length(sa.func.trim(knowledge_bases.c.collection_id)) > 0,
)
if 'workspace_uuid' in knowledge_bases.c:
# A partially migrated database may already have Workspace
# columns. Never mark a projected cloud row as legacy.
legacy_filter = sa.and_(
legacy_filter,
sa.or_(
knowledge_bases.c.workspace_uuid.is_(None),
knowledge_bases.c.workspace_uuid == workspace_uuid,
),
)
conn.execute(knowledge_bases.update().where(legacy_filter).values(legacy_vector_collection=True))
conn.execute(
knowledge_bases.update()
.where(knowledge_bases.c.legacy_vector_collection.is_(None))
.values(legacy_vector_collection=False)
)
columns = _columns(conn, 'knowledge_bases')
if columns['legacy_vector_collection']['nullable']:
with op.batch_alter_table('knowledge_bases') as batch_op:
batch_op.alter_column(
'legacy_vector_collection',
existing_type=columns['legacy_vector_collection']['type'],
nullable=False,
server_default=sa.false(),
)
def _drop_legacy_uniqueness(conn: sa.Connection) -> None:
if 'bot_admins' in _table_names(conn):
for constraint in _inspector(conn).get_unique_constraints('bot_admins'):
if tuple(constraint.get('column_names') or ()) == ('bot_uuid', 'launcher_type', 'launcher_id'):
with op.batch_alter_table('bot_admins') as batch_op:
batch_op.drop_constraint(constraint['name'], type_='unique')
break
if 'monitoring_feedback' in _table_names(conn):
dropped_constraint = False
for constraint in _inspector(conn).get_unique_constraints('monitoring_feedback'):
if tuple(constraint.get('column_names') or ()) == ('feedback_id',):
convention = {'uq': 'uq_%(table_name)s_%(column_0_name)s'}
constraint_name = constraint.get('name') or 'uq_monitoring_feedback_feedback_id'
with op.batch_alter_table(
'monitoring_feedback',
naming_convention=convention,
) as batch_op:
batch_op.drop_constraint(constraint_name, type_='unique')
dropped_constraint = True
break
if not dropped_constraint:
for index in _inspector(conn).get_indexes('monitoring_feedback'):
if index.get('unique') and tuple(index.get('column_names') or ()) == ('feedback_id',):
op.drop_index(index['name'], table_name='monitoring_feedback')
def _create_index_if_missing(
conn: sa.Connection,
table_name: str,
name: str,
columns: tuple[str, ...],
unique: bool,
predicate: sa.TextClause | None,
) -> None:
if name in _index_names(conn, table_name):
return
if unique and predicate is None and columns in _unique_column_sets(conn, table_name):
return
kwargs = {}
if predicate is not None:
kwargs = {'sqlite_where': predicate, 'postgresql_where': predicate}
op.create_index(name, table_name, list(columns), unique=unique, **kwargs)
def _validate_scoped_unique_data(conn: sa.Connection) -> None:
checks = (
('mcp_servers', ('workspace_uuid', 'name')),
('knowledge_bases', ('workspace_uuid', 'collection_id')),
)
for table_name, column_names in checks:
if table_name not in _table_names(conn):
continue
columns = _columns(conn, table_name)
if not all(column_name in columns for column_name in column_names):
continue
table = sa.table(
table_name,
*(sa.column(column_name, columns[column_name]['type']) for column_name in column_names),
)
group_columns = [table.c[column_name] for column_name in column_names]
query = sa.select(*group_columns, sa.func.count()).group_by(*group_columns).having(sa.func.count() > 1)
if column_names[-1] == 'collection_id':
query = query.where(group_columns[-1].is_not(None))
duplicate = conn.execute(query.limit(1)).first()
if duplicate is not None:
raise RuntimeError(
f'Cannot create scoped unique key on {table_name}{column_names}: duplicate {duplicate!r}'
)
def _create_parent_and_scoped_indexes(conn: sa.Connection) -> None:
_validate_scoped_unique_data(conn)
tables = _table_names(conn)
for table_name, indexes in _SCOPED_INDEXES.items():
if table_name not in tables:
continue
available_columns = _columns(conn, table_name)
for name, columns, unique, predicate in indexes:
if all(column in available_columns for column in columns):
_create_index_if_missing(conn, table_name, name, columns, unique, predicate)
def _contract_table(conn: sa.Connection, table_name: str) -> None:
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns:
return
direct_workspace_fk = _foreign_key_exists(
conn,
table_name,
('workspace_uuid',),
'workspaces',
('uuid',),
)
current_pk = tuple(_inspector(conn).get_pk_constraint(table_name).get('constrained_columns') or ())
desired_pk = _COMPOSITE_PRIMARY_KEYS.get(table_name)
missing_composite_fks = [
foreign_key
for foreign_key in _COMPOSITE_FOREIGN_KEYS.get(table_name, ())
if not _foreign_key_exists(
conn,
table_name,
foreign_key[1],
foreign_key[2],
foreign_key[3],
)
]
needs_contract = (
columns['workspace_uuid']['nullable']
or not direct_workspace_fk
or (desired_pk is not None and current_pk != desired_pk)
or bool(missing_composite_fks)
)
if not needs_contract:
return
naming = {
'pk': 'pk_%(table_name)s',
'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
}
pk_name = _inspector(conn).get_pk_constraint(table_name).get('name') or f'pk_{table_name}'
with op.batch_alter_table(table_name, naming_convention=naming) as batch_op:
if columns['workspace_uuid']['nullable']:
batch_op.alter_column(
'workspace_uuid',
existing_type=columns['workspace_uuid']['type'],
nullable=False,
)
if desired_pk is not None and current_pk != desired_pk:
batch_op.drop_constraint(pk_name, type_='primary')
batch_op.create_primary_key(f'pk_{table_name}', list(desired_pk))
if not direct_workspace_fk:
batch_op.create_foreign_key(
f'fk_{table_name}_workspace',
'workspaces',
['workspace_uuid'],
['uuid'],
ondelete='CASCADE',
)
for name, local_columns, referred_table, referred_columns, ondelete in missing_composite_fks:
batch_op.create_foreign_key(
name,
referred_table,
list(local_columns),
list(referred_columns),
ondelete=ondelete,
)
def _contract_workspace_columns(conn: sa.Connection) -> None:
tables = _table_names(conn)
# Parents must be contracted before their children so SQLite can validate
# the exact composite target key during a batch-table rebuild.
order = (
'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',
)
for table_name in order:
if table_name in tables:
_contract_table(conn, table_name)
def _migrate_workspace_metadata(conn: sa.Connection, workspace_uuid: str | None) -> None:
tables = _table_names(conn)
if 'workspaces' not in tables:
return
if 'workspace_metadata' not in tables:
op.create_table(
'workspace_metadata',
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('key', sa.String(255), nullable=False),
sa.Column('value', sa.String(255), nullable=True),
sa.ForeignKeyConstraint(
['workspace_uuid'],
['workspaces.uuid'],
name='fk_workspace_metadata_workspace',
ondelete='CASCADE',
),
sa.PrimaryKeyConstraint('workspace_uuid', 'key', name='pk_workspace_metadata'),
)
if workspace_uuid is None or 'metadata' not in tables:
return
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
workspace_metadata = sa.table(
'workspace_metadata',
sa.column('workspace_uuid', sa.String(36)),
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
tenant_keys = ('wizard_status', 'wizard_progress', 'rag_plugin_migration_needed')
rows = conn.execute(sa.select(metadata.c.key, metadata.c.value).where(metadata.c.key.in_(tenant_keys))).all()
for key, value in rows:
exists = conn.execute(
sa.select(workspace_metadata.c.key).where(
workspace_metadata.c.workspace_uuid == workspace_uuid,
workspace_metadata.c.key == key,
)
).first()
if exists is None:
conn.execute(
workspace_metadata.insert().values(
workspace_uuid=workspace_uuid,
key=key,
value=value,
)
)
if rows:
conn.execute(metadata.delete().where(metadata.c.key.in_(tenant_keys)))
def _validate_contract(conn: sa.Connection) -> None:
for table_name in _TENANT_TABLES:
if table_name not in _table_names(conn):
continue
columns = _columns(conn, table_name)
if 'workspace_uuid' not in columns or columns['workspace_uuid']['nullable']:
raise RuntimeError(f'{table_name}.workspace_uuid was not contracted to NOT NULL')
table = sa.table(table_name, sa.column('workspace_uuid', sa.String(36)))
if conn.scalar(sa.select(sa.func.count()).select_from(table).where(table.c.workspace_uuid.is_(None))):
raise RuntimeError(f'{table_name} still contains unscoped rows')
if conn.dialect.name == 'sqlite':
violations = conn.execute(sa.text('PRAGMA foreign_key_check')).all()
if violations:
raise RuntimeError(f'SQLite foreign key validation failed: {violations[:5]!r}')
def upgrade() -> None:
conn = op.get_bind()
_upgrade_normalized_email(conn)
workspace_uuid = _default_workspace_uuid(conn)
_mark_legacy_vector_collections(conn, workspace_uuid)
_expand_workspace_columns(conn, workspace_uuid)
_expand_and_hash_api_keys(conn, workspace_uuid)
_drop_legacy_uniqueness(conn)
_create_parent_and_scoped_indexes(conn)
_contract_workspace_columns(conn)
_migrate_workspace_metadata(conn, workspace_uuid)
_validate_contract(conn)
def downgrade() -> None:
raise RuntimeError(
'0010_scope_resources is intentionally irreversible because plaintext API key secrets were securely removed'
)
@@ -0,0 +1,201 @@
"""enforce PostgreSQL tenant isolation with exact discovery contracts
Revision ID: 0011_postgres_tenant_rls
Revises: 0010_scope_resources
Create Date: 2026-07-19
The table and policy lists are deliberately duplicated from the runtime
contract. Alembic revisions must remain self-contained after application code
evolves. Discovery policies are SELECT-only and reveal the minimum rows needed
to turn an authenticated credential into one Workspace transaction.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0011_postgres_tenant_rls'
down_revision = '0010_scope_resources'
branch_labels = None
depends_on = None
_POLICY_NAME = 'langbot_workspace_isolation'
_ACCOUNT_POLICY_NAME = 'langbot_account_discovery'
_API_KEY_POLICY_NAME = 'langbot_api_key_discovery'
_INVITATION_POLICY_NAME = 'langbot_invitation_discovery'
_INSTANCE_POLICY_NAME = 'langbot_instance_discovery'
_TENANT_SETTING = 'langbot.workspace_uuid'
_ACCOUNT_SETTING = 'langbot.account_uuid'
_API_KEY_HASH_SETTING = 'langbot.api_key_hash'
_INVITATION_HASH_SETTING = 'langbot.invitation_hash'
_INSTANCE_SETTING = 'langbot.instance_uuid'
_OSS_WORKSPACE_METADATA_KEY = 'oss_workspace_uuid'
_TENANT_TABLE_COLUMNS: dict[str, str] = {
'workspaces': 'uuid',
'workspace_memberships': 'workspace_uuid',
'workspace_invitations': 'workspace_uuid',
'workspace_execution_states': 'workspace_uuid',
'workspace_metadata': 'workspace_uuid',
'api_keys': 'workspace_uuid',
'bots': 'workspace_uuid',
'bot_admins': 'workspace_uuid',
'binary_storages': 'workspace_uuid',
'mcp_servers': 'workspace_uuid',
'model_providers': 'workspace_uuid',
'llm_models': 'workspace_uuid',
'embedding_models': 'workspace_uuid',
'rerank_models': 'workspace_uuid',
'legacy_pipelines': 'workspace_uuid',
'pipeline_run_records': 'workspace_uuid',
'plugin_settings': 'workspace_uuid',
'knowledge_bases': 'workspace_uuid',
'knowledge_base_files': 'workspace_uuid',
'knowledge_base_chunks': 'workspace_uuid',
'webhooks': 'workspace_uuid',
'monitoring_messages': 'workspace_uuid',
'monitoring_llm_calls': 'workspace_uuid',
'monitoring_tool_calls': 'workspace_uuid',
'monitoring_sessions': 'workspace_uuid',
'monitoring_errors': 'workspace_uuid',
'monitoring_embedding_calls': 'workspace_uuid',
'monitoring_feedback': 'workspace_uuid',
}
def _setting(name: str) -> str:
return f"NULLIF(current_setting('{name}', true), '')"
def _tenant_expression(column: str) -> str:
return f'{column}::text = {_setting(_TENANT_SETTING)}'
_DISCOVERY_POLICIES: dict[str, dict[str, str]] = {
'workspace_memberships': {
_ACCOUNT_POLICY_NAME: (f"account_uuid::text = {_setting(_ACCOUNT_SETTING)} AND status = 'active'"),
},
'workspace_execution_states': {
_INSTANCE_POLICY_NAME: (
f"instance_uuid = {_setting(_INSTANCE_SETTING)} AND state = 'active' AND write_fenced = false"
),
},
'api_keys': {
_API_KEY_POLICY_NAME: (
f"key_hash = {_setting(_API_KEY_HASH_SETTING)} AND status = 'active' "
'AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)'
),
},
'workspace_invitations': {
_INVITATION_POLICY_NAME: f'token_hash = {_setting(_INVITATION_HASH_SETTING)}',
},
}
def _quote_identifier(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def _record_oss_workspace_scope(conn: sa.Connection) -> None:
"""Keep PostgreSQL OSS usable after FORCE RLS is enabled."""
local_workspaces = (
conn.execute(sa.text("SELECT uuid FROM workspaces WHERE source = 'local' ORDER BY uuid")).scalars().all()
)
if len(local_workspaces) != 1:
return
existing = conn.execute(
sa.text('SELECT value FROM metadata WHERE key = :key'),
{'key': _OSS_WORKSPACE_METADATA_KEY},
).scalar_one_or_none()
if existing is None:
conn.execute(
sa.text('INSERT INTO metadata (key, value) VALUES (:key, :value)'),
{'key': _OSS_WORKSPACE_METADATA_KEY, 'value': local_workspaces[0]},
)
elif existing != local_workspaces[0]:
raise RuntimeError('Stored OSS Workspace scope does not match the local Workspace')
def _drop_all_policies(conn: sa.Connection, table_name: str) -> None:
policy_names = conn.execute(
sa.text(
"""
SELECT p.polname
FROM pg_policy p
JOIN pg_class c ON c.oid = p.polrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relname = :table_name
"""
),
{'table_name': table_name},
).scalars()
table = _quote_identifier(conn, table_name)
for policy in policy_names:
op.execute(sa.text(f'DROP POLICY {_quote_identifier(conn, policy)} ON {table}'))
def _create_policy(
conn: sa.Connection,
table_name: str,
policy_name: str,
expression: str,
*,
command: str,
) -> None:
table = _quote_identifier(conn, table_name)
policy = _quote_identifier(conn, policy_name)
if command == 'ALL':
sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC USING ({expression}) WITH CHECK ({expression})'
elif command == 'SELECT':
sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR SELECT TO PUBLIC USING ({expression})'
else: # pragma: no cover - migration-local invariant
raise AssertionError(f'Unsupported RLS command: {command}')
op.execute(sa.text(sql))
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
existing_tables = set(sa.inspect(conn).get_table_names())
missing_tables = set(_TENANT_TABLE_COLUMNS) - existing_tables
if missing_tables:
raise RuntimeError(f'Cannot enable tenant RLS before all tenant-owned tables exist: {sorted(missing_tables)!r}')
_record_oss_workspace_scope(conn)
for table_name, tenant_column in _TENANT_TABLE_COLUMNS.items():
table = _quote_identifier(conn, table_name)
_drop_all_policies(conn, table_name)
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
_create_policy(
conn,
table_name,
_POLICY_NAME,
_tenant_expression(_quote_identifier(conn, tenant_column)),
command='ALL',
)
for policy_name, expression in _DISCOVERY_POLICIES.get(table_name, {}).items():
_create_policy(conn, table_name, policy_name, expression, command='SELECT')
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
existing_tables = set(sa.inspect(conn).get_table_names())
for table_name in _TENANT_TABLE_COLUMNS:
if table_name not in existing_tables:
continue
table = _quote_identifier(conn, table_name)
_drop_all_policies(conn, table_name)
op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
@@ -0,0 +1,179 @@
"""add immutable plugin installation identity
Revision ID: 0012_plugin_identity
Revises: 0011_postgres_tenant_rls
Create Date: 2026-07-19
The migration gives every legacy row a random, stable installation UUID. A
legacy artifact digest is only a valid SHA-256-shaped recovery marker; Core
replaces it with the package digest and increments ``runtime_revision`` before
the next package apply.
"""
from __future__ import annotations
import hashlib
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0012_plugin_identity'
down_revision = '0011_postgres_tenant_rls'
branch_labels = None
depends_on = None
_TABLE = 'plugin_settings'
_INSTALLATION_INDEX = 'ix_plugin_settings_workspace_installation'
_INSTALLATION_UNIQUE = 'uq_plugin_settings_installation_uuid'
_REVISION_CHECK = 'ck_plugin_settings_runtime_revision_positive'
_DIGEST_CHECK = 'ck_plugin_settings_artifact_digest_length'
def _column_names(conn: sa.Connection) -> set[str]:
inspector = sa.inspect(conn)
if _TABLE not in inspector.get_table_names():
return set()
return {column['name'] for column in inspector.get_columns(_TABLE)}
def _legacy_digest(installation_uuid: str) -> str:
return hashlib.sha256(f'legacy-installation:{installation_uuid}'.encode()).hexdigest()
def _suspend_postgres_rls(conn: sa.Connection) -> tuple[bool, bool]:
"""Let the release migration backfill every tenant row after revision 0011."""
if conn.dialect.name != 'postgresql':
return False, False
row = conn.execute(
sa.text('SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = to_regclass(:table_name)'),
{'table_name': _TABLE},
).one()
rls_enabled, rls_forced = bool(row.relrowsecurity), bool(row.relforcerowsecurity)
if rls_forced:
op.execute(sa.text(f'ALTER TABLE {_TABLE} NO FORCE ROW LEVEL SECURITY'))
if rls_enabled:
op.execute(sa.text(f'ALTER TABLE {_TABLE} DISABLE ROW LEVEL SECURITY'))
return rls_enabled, rls_forced
def _restore_postgres_rls(conn: sa.Connection, state: tuple[bool, bool]) -> None:
if conn.dialect.name != 'postgresql':
return
rls_enabled, rls_forced = state
if rls_enabled:
op.execute(sa.text(f'ALTER TABLE {_TABLE} ENABLE ROW LEVEL SECURITY'))
if rls_forced:
op.execute(sa.text(f'ALTER TABLE {_TABLE} FORCE ROW LEVEL SECURITY'))
def _backfill(conn: sa.Connection) -> None:
table = sa.table(
_TABLE,
sa.column('workspace_uuid', sa.String(36)),
sa.column('plugin_author', sa.String(255)),
sa.column('plugin_name', sa.String(255)),
sa.column('installation_uuid', sa.String(36)),
sa.column('artifact_digest', sa.String(64)),
sa.column('runtime_revision', sa.Integer()),
)
rows = conn.execute(
sa.select(
table.c.workspace_uuid,
table.c.plugin_author,
table.c.plugin_name,
table.c.installation_uuid,
table.c.artifact_digest,
table.c.runtime_revision,
)
).all()
for row in rows:
installation_uuid = str(row.installation_uuid or uuid.uuid4())
values: dict[str, object] = {}
if not row.installation_uuid:
values['installation_uuid'] = installation_uuid
if not row.artifact_digest or len(str(row.artifact_digest)) != 64:
values['artifact_digest'] = _legacy_digest(installation_uuid)
if row.runtime_revision is None or row.runtime_revision < 1:
values['runtime_revision'] = 1
if values:
conn.execute(
table.update()
.where(table.c.workspace_uuid == row.workspace_uuid)
.where(table.c.plugin_author == row.plugin_author)
.where(table.c.plugin_name == row.plugin_name)
.values(**values)
)
def _constraint_names(conn: sa.Connection, kind: str) -> set[str]:
inspector = sa.inspect(conn)
getter = inspector.get_unique_constraints if kind == 'unique' else inspector.get_check_constraints
return {str(item.get('name')) for item in getter(_TABLE) if item.get('name')}
def upgrade() -> None:
conn = op.get_bind()
columns = _column_names(conn)
if not columns:
return
if 'installation_uuid' not in columns:
op.add_column(_TABLE, sa.Column('installation_uuid', sa.String(36), nullable=True))
if 'artifact_digest' not in columns:
op.add_column(_TABLE, sa.Column('artifact_digest', sa.String(64), nullable=True))
if 'runtime_revision' not in columns:
op.add_column(_TABLE, sa.Column('runtime_revision', sa.Integer(), nullable=True))
rls_state = _suspend_postgres_rls(conn)
try:
_backfill(conn)
finally:
_restore_postgres_rls(conn, rls_state)
# Fresh databases are created from current metadata before Alembic runs;
# guards make the revision safe for that path and for interrupted upgrades.
indexes = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE)}
unique_constraints = _constraint_names(conn, 'unique')
check_constraints = _constraint_names(conn, 'check')
with op.batch_alter_table(_TABLE) as batch:
batch.alter_column('installation_uuid', existing_type=sa.String(36), nullable=False)
batch.alter_column('artifact_digest', existing_type=sa.String(64), nullable=False)
batch.alter_column('runtime_revision', existing_type=sa.Integer(), nullable=False)
if _REVISION_CHECK not in check_constraints:
batch.create_check_constraint(_REVISION_CHECK, 'runtime_revision >= 1')
if _DIGEST_CHECK not in check_constraints:
batch.create_check_constraint(_DIGEST_CHECK, 'length(artifact_digest) = 64')
if _INSTALLATION_UNIQUE not in unique_constraints:
batch.create_unique_constraint(_INSTALLATION_UNIQUE, ['installation_uuid'])
if _INSTALLATION_INDEX not in indexes and _INSTALLATION_INDEX not in unique_constraints:
batch.create_index(
_INSTALLATION_INDEX,
['workspace_uuid', 'installation_uuid'],
unique=True,
)
def downgrade() -> None:
conn = op.get_bind()
columns = _column_names(conn)
if not columns:
return
indexes = {index['name'] for index in sa.inspect(conn).get_indexes(_TABLE)}
checks = _constraint_names(conn, 'check')
uniques = _constraint_names(conn, 'unique')
with op.batch_alter_table(_TABLE) as batch:
if _INSTALLATION_INDEX in indexes:
batch.drop_index(_INSTALLATION_INDEX)
if _DIGEST_CHECK in checks:
batch.drop_constraint(_DIGEST_CHECK, type_='check')
if _REVISION_CHECK in checks:
batch.drop_constraint(_REVISION_CHECK, type_='check')
if _INSTALLATION_UNIQUE in uniques:
batch.drop_constraint(_INSTALLATION_UNIQUE, type_='unique')
for column_name in ('runtime_revision', 'artifact_digest', 'installation_uuid'):
if column_name in columns:
batch.drop_column(column_name)
@@ -0,0 +1,382 @@
"""create tenant-scoped pgvector storage in the business database
Revision ID: 0013_tenant_pgvector
Revises: 0012_plugin_identity
Create Date: 2026-07-19
The Cloud application role never executes this DDL. A release migration role
installs pgvector once, creates an untyped vector column, and builds a bounded
set of expression/partial ANN indexes. Existing legacy rows are migrated only
when each row maps unambiguously to one knowledge base.
"""
from __future__ import annotations
import contextlib
import typing
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
revision = '0013_tenant_pgvector'
down_revision = '0012_plugin_identity'
branch_labels = None
depends_on = None
_VECTOR_TABLE = 'langbot_vectors'
_LEGACY_TABLE = 'langbot_vectors_legacy_0013'
_TENANT_POLICY = 'langbot_workspace_isolation'
_TENANT_SETTING = 'langbot.workspace_uuid'
_KB_DIMENSION_CHECK = 'ck_knowledge_bases_embedding_dimension_positive'
_VECTOR_DIMENSION_CHECK = 'ck_langbot_vectors_embedding_dimension'
_VECTOR_ALLOWED_CHECK = 'ck_langbot_vectors_embedding_dimension_enabled'
_ALLOWED_DIMENSIONS = (384, 512, 768, 1024, 1536)
_LEGACY_SOURCE_TABLES = (
'knowledge_bases',
'knowledge_base_files',
'knowledge_base_chunks',
)
def _quote(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def _columns(conn: sa.Connection, table_name: str) -> set[str]:
inspector = sa.inspect(conn)
if table_name not in inspector.get_table_names():
return set()
return {column['name'] for column in inspector.get_columns(table_name)}
def _checks(conn: sa.Connection, table_name: str) -> set[str]:
return {str(item['name']) for item in sa.inspect(conn).get_check_constraints(table_name) if item.get('name')}
def _ensure_knowledge_base_dimension(conn: sa.Connection) -> None:
columns = _columns(conn, 'knowledge_bases')
if 'embedding_dimension' not in columns:
op.add_column('knowledge_bases', sa.Column('embedding_dimension', sa.Integer(), nullable=True))
if _KB_DIMENSION_CHECK not in _checks(conn, 'knowledge_bases'):
with op.batch_alter_table('knowledge_bases') as batch:
batch.create_check_constraint(
_KB_DIMENSION_CHECK,
'embedding_dimension IS NULL OR embedding_dimension > 0',
)
def _create_vector_table() -> None:
enabled = ', '.join(str(item) for item in _ALLOWED_DIMENSIONS)
op.create_table(
_VECTOR_TABLE,
sa.Column('workspace_uuid', sa.String(36), nullable=False),
sa.Column('knowledge_base_uuid', sa.String(255), nullable=False),
sa.Column('vector_id', sa.String(255), nullable=False),
sa.Column('embedding_dimension', sa.Integer(), nullable=False),
sa.Column('embedding', Vector(), nullable=False),
sa.Column('text', sa.Text(), nullable=True),
sa.Column('file_id', sa.String(255), nullable=True),
sa.Column('chunk_uuid', sa.String(255), nullable=True),
sa.PrimaryKeyConstraint(
'workspace_uuid',
'knowledge_base_uuid',
'vector_id',
name='pk_langbot_vectors',
),
sa.ForeignKeyConstraint(
['workspace_uuid', 'knowledge_base_uuid'],
['knowledge_bases.workspace_uuid', 'knowledge_bases.uuid'],
name='fk_langbot_vectors_workspace_kb',
ondelete='CASCADE',
),
sa.CheckConstraint(
'vector_dims(embedding) = embedding_dimension',
name=_VECTOR_DIMENSION_CHECK,
),
sa.CheckConstraint(
f'embedding_dimension IN ({enabled})',
name=_VECTOR_ALLOWED_CHECK,
),
)
op.create_index(
'ix_langbot_vectors_workspace_kb_file',
_VECTOR_TABLE,
['workspace_uuid', 'knowledge_base_uuid', 'file_id'],
)
op.create_index(
'ix_langbot_vectors_workspace_kb_chunk',
_VECTOR_TABLE,
['workspace_uuid', 'knowledge_base_uuid', 'chunk_uuid'],
)
def _legacy_mapping_predicate() -> str:
return """
kb.collection_id = legacy.collection
OR EXISTS (
SELECT 1
FROM knowledge_base_files AS files
LEFT JOIN knowledge_base_chunks AS chunks
ON chunks.workspace_uuid = files.workspace_uuid
AND chunks.file_id = files.uuid
WHERE files.workspace_uuid = kb.workspace_uuid
AND files.kb_id = kb.uuid
AND (files.uuid = legacy.file_id OR chunks.uuid = legacy.chunk_uuid)
)
"""
def _legacy_source_rls_states(conn: sa.Connection) -> dict[str, tuple[bool, bool]]:
rows = (
conn.execute(
sa.text(
"""
SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class AS c
JOIN pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname IN :table_names
AND c.relkind IN ('r', 'p')
"""
).bindparams(sa.bindparam('table_names', expanding=True)),
{'table_names': _LEGACY_SOURCE_TABLES},
)
.mappings()
.all()
)
states = {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows}
missing = set(_LEGACY_SOURCE_TABLES) - set(states)
if missing:
raise RuntimeError(f'Legacy pgvector source tables are missing: {sorted(missing)!r}')
return states
@contextlib.contextmanager
def _suspend_legacy_source_rls(conn: sa.Connection) -> typing.Iterator[None]:
"""Temporarily let the table-owning migrator map all legacy tenant rows.
Revision 0011 enables and forces RLS on each source table. The release
migrator intentionally has neither superuser nor BYPASSRLS, so even a table
owner cannot read those rows until FORCE RLS is paused. Preserve both flags
independently and restore them in ``finally`` so mixed pre-existing states
survive successful, rejected, and interrupted legacy migrations.
"""
states = _legacy_source_rls_states(conn)
try:
for table_name in _LEGACY_SOURCE_TABLES:
table = _quote(conn, table_name)
conn.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
conn.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
yield
finally:
for table_name in _LEGACY_SOURCE_TABLES:
table = _quote(conn, table_name)
rls_enabled, rls_forced = states[table_name]
enabled_clause = 'ENABLE' if rls_enabled else 'DISABLE'
forced_clause = 'FORCE' if rls_forced else 'NO FORCE'
conn.execute(sa.text(f'ALTER TABLE {table} {enabled_clause} ROW LEVEL SECURITY'))
conn.execute(sa.text(f'ALTER TABLE {table} {forced_clause} ROW LEVEL SECURITY'))
def _migrate_legacy_rows(conn: sa.Connection) -> None:
predicate = _legacy_mapping_predicate()
ambiguous = conn.execute(
sa.text(
f"""
WITH candidates AS (
SELECT legacy.id, kb.workspace_uuid, kb.uuid AS knowledge_base_uuid
FROM {_LEGACY_TABLE} AS legacy
JOIN knowledge_bases AS kb ON ({predicate})
), candidate_counts AS (
SELECT id, COUNT(*) AS count
FROM candidates
GROUP BY id
)
SELECT legacy.id, COALESCE(candidate_counts.count, 0) AS candidate_count
FROM {_LEGACY_TABLE} AS legacy
LEFT JOIN candidate_counts ON candidate_counts.id = legacy.id
WHERE COALESCE(candidate_counts.count, 0) <> 1
LIMIT 1
"""
)
).first()
if ambiguous is not None:
raise RuntimeError(
'Legacy pgvector row cannot be mapped to exactly one Workspace/knowledge base: '
f'{ambiguous.id!r} has {ambiguous.candidate_count} candidates'
)
conn.execute(
sa.text(
f"""
INSERT INTO {_VECTOR_TABLE} (
workspace_uuid,
knowledge_base_uuid,
vector_id,
embedding_dimension,
embedding,
text,
file_id,
chunk_uuid
)
SELECT
kb.workspace_uuid,
kb.uuid,
legacy.id,
vector_dims(legacy.embedding),
legacy.embedding,
legacy.text,
legacy.file_id,
legacy.chunk_uuid
FROM {_LEGACY_TABLE} AS legacy
JOIN knowledge_bases AS kb ON ({predicate})
"""
)
)
mixed_dimension = conn.execute(
sa.text(
f"""
SELECT workspace_uuid, knowledge_base_uuid
FROM {_VECTOR_TABLE}
GROUP BY workspace_uuid, knowledge_base_uuid
HAVING MIN(embedding_dimension) <> MAX(embedding_dimension)
LIMIT 1
"""
)
).first()
if mixed_dimension is not None:
raise RuntimeError('Legacy knowledge base contains mixed embedding dimensions')
conn.execute(
sa.text(
f"""
UPDATE knowledge_bases AS kb
SET embedding_dimension = dimensions.embedding_dimension
FROM (
SELECT workspace_uuid, knowledge_base_uuid, MIN(embedding_dimension) AS embedding_dimension
FROM {_VECTOR_TABLE}
GROUP BY workspace_uuid, knowledge_base_uuid
) AS dimensions
WHERE kb.workspace_uuid = dimensions.workspace_uuid
AND kb.uuid = dimensions.knowledge_base_uuid
AND kb.embedding_dimension IS NULL
"""
)
)
def _drop_all_policies(conn: sa.Connection) -> None:
table = _quote(conn, _VECTOR_TABLE)
policies = conn.execute(
sa.text(
"""
SELECT p.polname
FROM pg_policy p
JOIN pg_class c ON c.oid = p.polrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relname = :table_name
"""
),
{'table_name': _VECTOR_TABLE},
).scalars()
for policy_name in policies:
op.execute(sa.text(f'DROP POLICY {_quote(conn, policy_name)} ON {table}'))
def _enable_rls(conn: sa.Connection) -> None:
table = _quote(conn, _VECTOR_TABLE)
policy = _quote(conn, _TENANT_POLICY)
expression = f"workspace_uuid::text = NULLIF(current_setting('{_TENANT_SETTING}', true), '')"
_drop_all_policies(conn)
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
def _create_ann_indexes(conn: sa.Connection) -> None:
table = _quote(conn, _VECTOR_TABLE)
for dimension in _ALLOWED_DIMENSIONS:
index = _quote(conn, f'ix_langbot_vectors_hnsw_cosine_{dimension}')
op.execute(
sa.text(
f'CREATE INDEX {index} ON {table} USING hnsw '
f'((embedding::vector({dimension})) vector_cosine_ops) '
f'WHERE embedding_dimension = {dimension}'
)
)
def upgrade() -> None:
conn = op.get_bind()
if 'knowledge_bases' not in sa.inspect(conn).get_table_names():
if conn.dialect.name == 'postgresql':
# The supported PostgreSQL release path creates the business
# tables before stamping 0010 and reaching this migration. Missing
# knowledge_bases therefore means the operator bypassed the
# release bootstrap or the schema is incomplete; stamping head in
# that state would make Cloud runtime validation unrecoverable.
raise RuntimeError('PostgreSQL release migration requires the knowledge_bases table')
# A direct empty SQLite Alembic walk is still used by migration tooling;
# Core creates the complete ORM schema on its following compatibility
# pass, including the portable embedding_dimension field.
return
_ensure_knowledge_base_dimension(conn)
# pgvector storage is PostgreSQL-only, but ``embedding_dimension`` is an
# ORM field used by both deployment modes. Existing OSS SQLite databases
# must receive the column before this revision becomes a no-op.
if conn.dialect.name != 'postgresql':
return
op.execute(sa.text('CREATE EXTENSION IF NOT EXISTS vector'))
columns = _columns(conn, _VECTOR_TABLE)
if columns:
scoped_columns = {
'workspace_uuid',
'knowledge_base_uuid',
'vector_id',
'embedding_dimension',
'embedding',
}
if scoped_columns.issubset(columns):
raise RuntimeError('Tenant pgvector table exists before its owning release migration')
legacy_columns = {'id', 'collection', 'embedding'}
if not legacy_columns.issubset(columns):
raise RuntimeError('Existing pgvector table has an unsupported schema')
if _LEGACY_TABLE in sa.inspect(conn).get_table_names():
raise RuntimeError(f'Interrupted pgvector migration left {_LEGACY_TABLE!r} behind')
op.rename_table(_VECTOR_TABLE, _LEGACY_TABLE)
_create_vector_table()
if _LEGACY_TABLE in sa.inspect(conn).get_table_names():
with _suspend_legacy_source_rls(conn):
_migrate_legacy_rows(conn)
op.drop_table(_LEGACY_TABLE)
_create_ann_indexes(conn)
_enable_rls(conn)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name == 'postgresql' and _VECTOR_TABLE in sa.inspect(conn).get_table_names():
_drop_all_policies(conn)
op.drop_table(_VECTOR_TABLE)
columns = _columns(conn, 'knowledge_bases')
if 'embedding_dimension' in columns:
checks = _checks(conn, 'knowledge_bases')
with op.batch_alter_table('knowledge_bases') as batch:
if _KB_DIMENSION_CHECK in checks:
batch.drop_constraint(_KB_DIMENSION_CHECK, type_='check')
batch.drop_column('embedding_dimension')
@@ -0,0 +1,268 @@
"""add the Cloud directory projection persistence boundary
Revision ID: 0014_cloud_directory
Revises: 0013_tenant_pgvector
Create Date: 2026-07-24
The open Core projector receives already-verified control-plane data and is the
only runtime path allowed to mutate projected Workspace directory rows. Its
transaction-local instance setting is intentionally distinct from both normal
Workspace scope and the read-only instance discovery scope.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0014_cloud_directory'
down_revision = '0013_tenant_pgvector'
branch_labels = None
depends_on = None
_STATE_TABLE = 'directory_projection_states'
_INBOX_TABLE = 'directory_projection_inbox'
_DIRECTORY_POLICY_NAME = 'langbot_directory_projection'
_TENANT_POLICY_NAME = 'langbot_workspace_isolation'
_LOCAL_WRITE_POLICY_NAME = 'langbot_workspace_local_directory_write'
_DIRECTORY_SETTING = 'langbot.directory_instance_uuid'
_TENANT_SETTING = 'langbot.workspace_uuid'
_PROJECTED_TENANT_TABLES = (
'workspaces',
'workspace_memberships',
'workspace_execution_states',
)
def _setting(name: str) -> str:
return f"NULLIF(current_setting('{name}', true), '')"
def _quote(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def _create_tables(conn: sa.Connection) -> None:
existing_tables = set(sa.inspect(conn).get_table_names())
if _STATE_TABLE not in existing_tables:
op.create_table(
_STATE_TABLE,
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('cursor', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('snapshot_coverage_cursor', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('snapshot_fingerprint', sa.Text(), nullable=False),
sa.Column('last_applied_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
'cursor >= 0',
name='ck_directory_projection_state_cursor',
),
sa.CheckConstraint(
'snapshot_coverage_cursor >= 0 AND snapshot_coverage_cursor <= cursor',
name='ck_directory_projection_state_snapshot_coverage',
),
sa.CheckConstraint(
'length(snapshot_fingerprint) = 64',
name='ck_directory_projection_state_fingerprint',
),
sa.PrimaryKeyConstraint('instance_uuid'),
)
if _INBOX_TABLE not in existing_tables:
op.create_table(
_INBOX_TABLE,
sa.Column('instance_uuid', sa.String(255), nullable=False),
sa.Column('event_uuid', sa.String(36), nullable=False),
sa.Column('cursor', sa.BigInteger(), nullable=False),
sa.Column('event_type', sa.String(128), nullable=False),
sa.Column('revision', sa.BigInteger(), nullable=False),
sa.Column('fingerprint', sa.Text(), nullable=False),
sa.Column(
'received_at',
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column('applied_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
'cursor > 0',
name='ck_directory_projection_inbox_cursor',
),
sa.CheckConstraint(
'revision > 0',
name='ck_directory_projection_inbox_revision',
),
sa.CheckConstraint(
'length(fingerprint) = 64',
name='ck_directory_projection_inbox_fingerprint',
),
sa.PrimaryKeyConstraint('instance_uuid', 'event_uuid'),
sa.UniqueConstraint(
'instance_uuid',
'cursor',
name='uq_directory_projection_inbox_cursor',
),
)
op.create_index(
'ix_directory_projection_inbox_pending',
_INBOX_TABLE,
['instance_uuid', 'applied_at', 'cursor'],
unique=False,
)
def _drop_policy(conn: sa.Connection, table_name: str, policy_name: str) -> None:
table = _quote(conn, table_name)
policy = _quote(conn, policy_name)
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
def _create_policy(
conn: sa.Connection,
table_name: str,
policy_name: str,
expression: str,
*,
command: str = 'ALL',
) -> None:
table = _quote(conn, table_name)
policy = _quote(conn, policy_name)
_drop_policy(conn, table_name, policy_name)
op.execute(sa.text(f'ALTER TABLE {table} ENABLE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} FORCE ROW LEVEL SECURITY'))
if command == 'SELECT':
sql = f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR SELECT TO PUBLIC USING ({expression})'
elif command == 'ALL':
sql = (
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
else: # pragma: no cover - migration-local invariant.
raise AssertionError(f'Unsupported RLS policy command: {command}')
op.execute(sa.text(sql))
def _install_postgres_policies(conn: sa.Connection) -> None:
existing_tables = set(sa.inspect(conn).get_table_names())
required_tables = set(_PROJECTED_TENANT_TABLES) | {_STATE_TABLE, _INBOX_TABLE}
missing_tables = required_tables - existing_tables
if missing_tables:
raise RuntimeError(
f'Cannot enable Cloud directory projection RLS before all required tables exist: {sorted(missing_tables)!r}'
)
directory_setting = _setting(_DIRECTORY_SETTING)
tenant_setting = _setting(_TENANT_SETTING)
directory_expressions = {
'workspaces': (f"instance_uuid::text = {directory_setting} AND source = 'cloud_projection'"),
'workspace_memberships': (
'EXISTS ('
'SELECT 1 FROM workspaces AS directory_workspace '
'WHERE directory_workspace.uuid = workspace_memberships.workspace_uuid '
f'AND directory_workspace.instance_uuid::text = {directory_setting} '
"AND directory_workspace.source = 'cloud_projection'"
')'
),
'workspace_execution_states': (
f"instance_uuid::text = {directory_setting} AND source = 'cloud' AND EXISTS ("
'SELECT 1 FROM workspaces AS directory_workspace '
'WHERE directory_workspace.uuid = workspace_execution_states.workspace_uuid '
f'AND directory_workspace.instance_uuid::text = {directory_setting} '
"AND directory_workspace.source = 'cloud_projection'"
')'
),
_STATE_TABLE: f'instance_uuid::text = {directory_setting}',
_INBOX_TABLE: f'instance_uuid::text = {directory_setting}',
}
tenant_expressions = {
'workspaces': f'uuid::text = {tenant_setting}',
'workspace_memberships': f'workspace_uuid::text = {tenant_setting}',
'workspace_execution_states': f'workspace_uuid::text = {tenant_setting}',
}
local_write_expressions = {
'workspaces': f"uuid::text = {tenant_setting} AND source = 'local'",
'workspace_memberships': (
f'workspace_uuid::text = {tenant_setting} AND EXISTS ('
'SELECT 1 FROM workspaces AS local_workspace '
'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid '
"AND local_workspace.source = 'local'"
')'
),
'workspace_execution_states': (
f'workspace_uuid::text = {tenant_setting} AND EXISTS ('
'SELECT 1 FROM workspaces AS local_workspace '
'WHERE local_workspace.uuid = workspace_execution_states.workspace_uuid '
"AND local_workspace.source = 'local'"
')'
),
}
for table_name in _PROJECTED_TENANT_TABLES:
_create_policy(
conn,
table_name,
_TENANT_POLICY_NAME,
tenant_expressions[table_name],
command='SELECT',
)
_create_policy(
conn,
table_name,
_LOCAL_WRITE_POLICY_NAME,
local_write_expressions[table_name],
)
_create_policy(
conn,
table_name,
_DIRECTORY_POLICY_NAME,
directory_expressions[table_name],
)
for table_name in (_STATE_TABLE, _INBOX_TABLE):
_create_policy(
conn,
table_name,
_DIRECTORY_POLICY_NAME,
directory_expressions[table_name],
)
def upgrade() -> None:
conn = op.get_bind()
_create_tables(conn)
if conn.dialect.name == 'postgresql':
_install_postgres_policies(conn)
def downgrade() -> None:
conn = op.get_bind()
existing_tables = set(sa.inspect(conn).get_table_names())
if conn.dialect.name == 'postgresql':
tenant_setting = _setting(_TENANT_SETTING)
tenant_columns = {
'workspaces': 'uuid',
'workspace_memberships': 'workspace_uuid',
'workspace_execution_states': 'workspace_uuid',
}
for table_name in _PROJECTED_TENANT_TABLES:
if table_name not in existing_tables:
continue
_drop_policy(conn, table_name, _DIRECTORY_POLICY_NAME)
_drop_policy(conn, table_name, _LOCAL_WRITE_POLICY_NAME)
_create_policy(
conn,
table_name,
_TENANT_POLICY_NAME,
f'{tenant_columns[table_name]}::text = {tenant_setting}',
)
for table_name in (_STATE_TABLE, _INBOX_TABLE):
if table_name not in existing_tables:
continue
_drop_policy(conn, table_name, _DIRECTORY_POLICY_NAME)
table = _quote(conn, table_name)
op.execute(sa.text(f'ALTER TABLE {table} NO FORCE ROW LEVEL SECURITY'))
op.execute(sa.text(f'ALTER TABLE {table} DISABLE ROW LEVEL SECURITY'))
if _INBOX_TABLE in existing_tables:
op.drop_table(_INBOX_TABLE)
if _STATE_TABLE in existing_tables:
op.drop_table(_STATE_TABLE)
@@ -0,0 +1,75 @@
"""allow Core-owned collaboration writes on Cloud Workspaces
Revision ID: 0015_cloud_core_collab
Revises: 0014_cloud_directory
Create Date: 2026-07-26
Cloud-projected Workspace identity remains projected by the directory
boundary, but membership role/remove and invitation acceptance are now owned
by Core tenant scope.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = '0015_cloud_core_collab'
down_revision = '0014_cloud_directory'
branch_labels = None
depends_on = None
_TABLE_NAME = 'workspace_memberships'
_POLICY_NAME = 'langbot_workspace_local_directory_write'
_TENANT_SETTING = 'langbot.workspace_uuid'
def _setting(name: str) -> str:
return f"NULLIF(current_setting('{name}', true), '')"
def _quote(conn: sa.Connection, identifier: str) -> str:
return conn.dialect.identifier_preparer.quote(identifier)
def _drop_policy(conn: sa.Connection) -> None:
table = _quote(conn, _TABLE_NAME)
policy = _quote(conn, _POLICY_NAME)
op.execute(sa.text(f'DROP POLICY IF EXISTS {policy} ON {table}'))
def _create_policy(conn: sa.Connection, expression: str) -> None:
table = _quote(conn, _TABLE_NAME)
policy = _quote(conn, _POLICY_NAME)
op.execute(
sa.text(
f'CREATE POLICY {policy} ON {table} AS PERMISSIVE FOR ALL TO PUBLIC '
f'USING ({expression}) WITH CHECK ({expression})'
)
)
def upgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
expression = f'workspace_uuid::text = {_setting(_TENANT_SETTING)}'
_drop_policy(conn)
_create_policy(conn, expression)
def downgrade() -> None:
conn = op.get_bind()
if conn.dialect.name != 'postgresql':
return
expression = (
f'workspace_uuid::text = {_setting(_TENANT_SETTING)} AND EXISTS ('
'SELECT 1 FROM workspaces AS local_workspace '
'WHERE local_workspace.uuid = workspace_memberships.workspace_uuid '
"AND local_workspace.source = 'local'"
')'
)
_drop_policy(conn)
_create_policy(conn, expression)
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -47,12 +48,28 @@ def _do_stamp(connection: Connection, revision: str = 'head') -> None:
command.stamp(cfg, revision)
def _do_downgrade(connection: Connection, revision: str) -> None:
"""Synchronous downgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.downgrade(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
def get_alembic_head() -> str:
"""Resolve the single release head without opening a database connection."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
head = ScriptDirectory.from_config(cfg).get_current_head()
if head is None:
raise RuntimeError('Alembic has no migration head')
return head
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
@@ -73,6 +90,13 @@ async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -
await conn.commit()
async def run_alembic_downgrade(async_engine: AsyncEngine, revision: str) -> None:
"""Run Alembic downgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_downgrade, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
@@ -121,6 +145,7 @@ if __name__ == '__main__':
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' downgrade <revision> — Downgrade database to a revision')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
@@ -140,6 +165,13 @@ if __name__ == '__main__':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'downgrade':
if len(sys.argv) < 3:
print('Usage: python -m langbot.pkg.persistence.alembic_runner downgrade <revision>')
sys.exit(1)
rev = sys.argv[2]
asyncio.run(run_alembic_downgrade(engine, rev))
print(f'Downgraded to: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
+9 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import abc
import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from ..core import app
@@ -30,8 +31,15 @@ class BaseDatabaseManager(abc.ABC):
engine: sqlalchemy_asyncio.AsyncEngine
def __init__(self, ap: app.Application) -> None:
def __init__(
self,
ap: app.Application,
*,
url_override: sqlalchemy.engine.URL | None = None,
) -> None:
self.ap = ap
self.url_override = url_override
self.persistence_mode: str | None = None
@abc.abstractmethod
async def initialize(self) -> None:
@@ -1,21 +1,167 @@
from __future__ import annotations
import sqlalchemy
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
from .. import database
from ..postgresql_url import normalize_asyncpg_url
MAX_POOL_CONNECTIONS = 100
MAX_POOL_TIMEOUT_SECONDS = 300
MAX_POOL_RECYCLE_SECONDS = 86_400
MAX_STATEMENT_TIMEOUT_MS = 300_000
MAX_LOCK_TIMEOUT_MS = 60_000
MAX_IDLE_TRANSACTION_TIMEOUT_MS = 300_000
@database.manager_class('postgresql')
class PostgreSQLDatabaseManager(database.BaseDatabaseManager):
"""PostgreSQL database manager"""
async def initialize(self) -> None:
postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {})
@staticmethod
def _pool_integer(
config: dict,
name: str,
default: int,
*,
minimum: int,
maximum: int,
) -> int:
value = config.get(name, default)
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
comparator = 'non-negative' if minimum == 0 else 'positive'
raise ValueError(f'database.postgresql.{name} must be a {comparator} integer no greater than {maximum}')
return value
host = postgresql_config.get('host', '127.0.0.1')
port = postgresql_config.get('port', 5432)
user = postgresql_config.get('user', 'postgres')
password = postgresql_config.get('password', 'postgres')
database = postgresql_config.get('database', 'postgres')
engine_url = f'postgresql+asyncpg://{user}:{password}@{host}:{port}/{database}'
self.engine = sqlalchemy_asyncio.create_async_engine(engine_url)
async def initialize(self) -> None:
self._pool_timeouts_total = 0
postgresql_config = self.ap.instance_config.data.get('database', {}).get('postgresql', {})
if not isinstance(postgresql_config, dict):
raise ValueError('database.postgresql must be an object')
if self.url_override is not None:
engine_url = self.url_override
else:
explicit_url = postgresql_config.get('url')
if explicit_url:
if not isinstance(explicit_url, str):
raise ValueError('database.postgresql.url must be a string')
try:
engine_url = sqlalchemy.engine.make_url(explicit_url)
except Exception:
raise ValueError('database.postgresql.url is invalid') from None
try:
engine_url = normalize_asyncpg_url(engine_url)
except ValueError:
raise ValueError('database.postgresql.url must use valid PostgreSQL asyncpg options') from None
else:
engine_url = sqlalchemy.URL.create(
'postgresql+asyncpg',
username=postgresql_config.get('user', 'postgres'),
password=postgresql_config.get('password', 'postgres'),
host=postgresql_config.get('host', '127.0.0.1'),
port=postgresql_config.get('port', 5432),
database=postgresql_config.get('database', 'postgres'),
)
self.pool_size = self._pool_integer(
postgresql_config,
'pool_size',
10,
minimum=1,
maximum=MAX_POOL_CONNECTIONS,
)
self.max_overflow = self._pool_integer(
postgresql_config,
'max_overflow',
10,
minimum=0,
maximum=MAX_POOL_CONNECTIONS,
)
if self.pool_size + self.max_overflow > MAX_POOL_CONNECTIONS:
raise ValueError(f'database.postgresql pool_size + max_overflow must not exceed {MAX_POOL_CONNECTIONS}')
self.pool_timeout_seconds = self._pool_integer(
postgresql_config,
'pool_timeout_seconds',
30,
minimum=1,
maximum=MAX_POOL_TIMEOUT_SECONDS,
)
self.pool_recycle_seconds = self._pool_integer(
postgresql_config,
'pool_recycle_seconds',
1800,
minimum=1,
maximum=MAX_POOL_RECYCLE_SECONDS,
)
connect_args = {}
self.statement_timeout_ms = 0
self.lock_timeout_ms = 0
self.idle_transaction_timeout_ms = 0
if self.persistence_mode == 'cloud_runtime':
self.statement_timeout_ms = self._pool_integer(
postgresql_config,
'statement_timeout_ms',
60_000,
minimum=1,
maximum=MAX_STATEMENT_TIMEOUT_MS,
)
self.lock_timeout_ms = self._pool_integer(
postgresql_config,
'lock_timeout_ms',
5_000,
minimum=1,
maximum=MAX_LOCK_TIMEOUT_MS,
)
self.idle_transaction_timeout_ms = self._pool_integer(
postgresql_config,
'idle_in_transaction_session_timeout_ms',
60_000,
minimum=1,
maximum=MAX_IDLE_TRANSACTION_TIMEOUT_MS,
)
connect_args = {
'server_settings': {
'statement_timeout': str(self.statement_timeout_ms),
'lock_timeout': str(self.lock_timeout_ms),
'idle_in_transaction_session_timeout': str(self.idle_transaction_timeout_ms),
}
}
self.engine = sqlalchemy_asyncio.create_async_engine(
engine_url,
pool_size=self.pool_size,
max_overflow=self.max_overflow,
pool_timeout=self.pool_timeout_seconds,
pool_recycle=self.pool_recycle_seconds,
pool_pre_ping=True,
**({'connect_args': connect_args} if connect_args else {}),
)
def resource_stats(self) -> dict[str, int]:
"""Return aggregate pool gauges without exposing connection details."""
pool = self.engine.pool
def read(name: str) -> int:
method = getattr(pool, name, None)
if not callable(method):
return 0
try:
return int(method())
except Exception:
return 0
return {
'configured_size': self.pool_size,
'configured_max_overflow': self.max_overflow,
'configured_capacity': self.pool_size + self.max_overflow,
'statement_timeout_ms': self.statement_timeout_ms,
'lock_timeout_ms': self.lock_timeout_ms,
'idle_in_transaction_session_timeout_ms': self.idle_transaction_timeout_ms,
'timeouts_total': self._pool_timeouts_total,
'checked_in': read('checkedin'),
'checked_out': read('checkedout'),
'overflow': max(read('overflow'), 0),
}
def record_pool_timeout(self) -> None:
self._pool_timeouts_total += 1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
"""Safe PostgreSQL URL normalization shared by runtime and migration jobs."""
from __future__ import annotations
import sqlalchemy
def normalize_asyncpg_url(url: sqlalchemy.engine.URL) -> sqlalchemy.engine.URL:
"""Select asyncpg and translate the common libpq TLS query spelling."""
if url.drivername == 'postgresql':
url = url.set(drivername='postgresql+asyncpg')
elif url.drivername != 'postgresql+asyncpg':
raise ValueError('PostgreSQL URL must use PostgreSQL with the asyncpg driver')
query = dict(url.query)
sslmode = query.pop('sslmode', None)
if sslmode is not None:
if 'ssl' in query and query['ssl'] != sslmode:
raise ValueError('PostgreSQL URL cannot specify conflicting ssl and sslmode options')
# SQLAlchemy expands URL query keys into asyncpg keyword arguments.
# asyncpg calls this keyword ``ssl`` even though PostgreSQL DSNs
# conventionally spell the same mode ``sslmode``.
query['ssl'] = sslmode
return url.set(query=query)
@@ -0,0 +1,190 @@
"""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)
@@ -0,0 +1,272 @@
"""Durable SQLite backups for destructive Alembic migration boundaries."""
from __future__ import annotations
import asyncio
import dataclasses
import datetime
import json
import os
import pathlib
import re
import secrets
import sqlite3
import tempfile
import typing
from sqlalchemy.ext.asyncio import AsyncEngine
class SQLiteMigrationBackupError(RuntimeError):
"""A verified migration backup could not be created or restored."""
@dataclasses.dataclass(frozen=True, slots=True)
class SQLiteMigrationBackup:
database_path: pathlib.Path
backup_path: pathlib.Path
manifest_path: pathlib.Path
source_revision: str
target_revision: str
created_at: str
def _safe_label(value: str) -> str:
label = re.sub(r'[^A-Za-z0-9_.-]+', '-', value).strip('-')
return label or 'unknown'
def _database_path(engine: AsyncEngine) -> pathlib.Path:
if engine.dialect.name != 'sqlite':
raise SQLiteMigrationBackupError('SQLite migration backups require a SQLite engine')
database = engine.url.database
if not database or database == ':memory:' or engine.url.query.get('mode') == 'memory':
raise SQLiteMigrationBackupError('Tenant schema migrations require a file-backed SQLite database for recovery')
database_path = pathlib.Path(database).expanduser()
if not database_path.is_absolute():
database_path = pathlib.Path.cwd() / database_path
database_path = database_path.resolve()
if not database_path.is_file():
raise SQLiteMigrationBackupError(f'SQLite database does not exist: {database_path}')
return database_path
def _open_read_only(path: pathlib.Path) -> sqlite3.Connection:
return sqlite3.connect(f'{path.as_uri()}?mode=ro', uri=True, timeout=30)
def _read_revision(connection: sqlite3.Connection) -> str | None:
has_version_table = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'"
).fetchone()
if has_version_table is None:
return None
rows = connection.execute('SELECT version_num FROM alembic_version').fetchall()
if not rows:
return None
if len(rows) != 1 or not isinstance(rows[0][0], str):
raise SQLiteMigrationBackupError('SQLite backup has an invalid Alembic revision table')
return rows[0][0]
def _verify_connection(connection: sqlite3.Connection, expected_revision: str) -> None:
quick_check = connection.execute('PRAGMA quick_check').fetchall()
if quick_check != [('ok',)]:
raise SQLiteMigrationBackupError(f'SQLite quick_check failed: {quick_check[:5]!r}')
actual_revision = _read_revision(connection)
if actual_revision != expected_revision:
raise SQLiteMigrationBackupError(
f'SQLite backup revision mismatch: {actual_revision!r} != {expected_revision!r}'
)
def _verify_file(path: pathlib.Path, expected_revision: str) -> None:
with _open_read_only(path) as connection:
_verify_connection(connection, expected_revision)
def _write_manifest(backup: SQLiteMigrationBackup, status: str, **extra: typing.Any) -> None:
payload: dict[str, typing.Any] = {
'version': 1,
'status': status,
'created_at': backup.created_at,
'database_path': str(backup.database_path),
'backup_path': str(backup.backup_path),
'source_revision': backup.source_revision,
'target_revision': backup.target_revision,
'quick_check': 'ok',
**extra,
}
backup.manifest_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.manifest_path.name}.',
suffix='.tmp',
dir=backup.manifest_path.parent,
)
temporary_path = pathlib.Path(temporary_name)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as file:
json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
file.write('\n')
file.flush()
os.fsync(file.fileno())
os.chmod(temporary_path, 0o600)
os.replace(temporary_path, backup.manifest_path)
_fsync_directory(backup.manifest_path.parent)
finally:
temporary_path.unlink(missing_ok=True)
def _fsync_file(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _fsync_directory(path: pathlib.Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _create_backup(
database_path: pathlib.Path,
source_revision: str,
target_revision: str,
) -> SQLiteMigrationBackup:
backup_directory = database_path.parent / 'migration-backups'
backup_directory.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_directory, 0o700)
created_at = datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H-%M-%S.%fZ')
stem = (
f'{database_path.stem}-pre-{_safe_label(target_revision)}-'
f'from-{_safe_label(source_revision)}-{created_at}-{secrets.token_hex(4)}'
)
backup_path = backup_directory / f'{stem}.sqlite3'
manifest_path = backup_directory / f'{stem}.json'
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{stem}.',
suffix='.creating',
dir=backup_directory,
)
os.close(descriptor)
temporary_path = pathlib.Path(temporary_name)
try:
with (
_open_read_only(database_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.execute('PRAGMA busy_timeout = 30000')
source.backup(destination)
destination.commit()
_verify_connection(destination, source_revision)
os.chmod(temporary_path, 0o600)
_fsync_file(temporary_path)
os.replace(temporary_path, backup_path)
_fsync_file(backup_path)
_fsync_directory(backup_directory)
backup = SQLiteMigrationBackup(
database_path=database_path,
backup_path=backup_path,
manifest_path=manifest_path,
source_revision=source_revision,
target_revision=target_revision,
created_at=created_at,
)
_write_manifest(backup, 'verified')
return backup
except Exception:
backup_path.unlink(missing_ok=True)
manifest_path.unlink(missing_ok=True)
raise
finally:
temporary_path.unlink(missing_ok=True)
async def create_verified_backup(
engine: AsyncEngine,
*,
source_revision: str,
target_revision: str,
) -> SQLiteMigrationBackup:
"""Create and verify an online-consistent backup next to instance data."""
database_path = _database_path(engine)
return await asyncio.to_thread(
_create_backup,
database_path,
source_revision,
target_revision,
)
def _restore_backup(backup: SQLiteMigrationBackup) -> None:
_verify_file(backup.backup_path, backup.source_revision)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f'.{backup.database_path.name}.',
suffix='.restoring',
dir=backup.database_path.parent,
)
os.close(descriptor)
temporary_path = pathlib.Path(temporary_name)
try:
with (
_open_read_only(backup.backup_path) as source,
sqlite3.connect(
temporary_path,
timeout=30,
) as destination,
):
source.backup(destination)
destination.commit()
_verify_connection(destination, backup.source_revision)
os.chmod(temporary_path, 0o600)
_fsync_file(temporary_path)
# A stale WAL could replay pages from the failed migration after the
# main database file is replaced. The engine is disposed before this
# function runs, so these exact sidecars are safe to remove.
for suffix in ('-wal', '-shm', '-journal'):
pathlib.Path(f'{backup.database_path}{suffix}').unlink(missing_ok=True)
os.replace(temporary_path, backup.database_path)
_fsync_file(backup.database_path)
_fsync_directory(backup.database_path.parent)
_verify_file(backup.database_path, backup.source_revision)
finally:
temporary_path.unlink(missing_ok=True)
async def restore_verified_backup(engine: AsyncEngine, backup: SQLiteMigrationBackup) -> None:
"""Atomically restore a verified backup after a migration failure."""
await engine.dispose()
await asyncio.to_thread(_restore_backup, backup)
await asyncio.to_thread(
_write_manifest,
backup,
'restored_after_failure',
restored_at=datetime.datetime.now(datetime.UTC).isoformat(),
)
async def mark_migration_succeeded(
backup: SQLiteMigrationBackup,
*,
completed_revision: str,
) -> None:
"""Mark a retained verified backup after its migration boundary succeeds."""
await asyncio.to_thread(
_write_manifest,
backup,
'migration_succeeded',
completed_at=datetime.datetime.now(datetime.UTC).isoformat(),
completed_revision=completed_revision,
)
File diff suppressed because it is too large Load Diff