"""Real PostgreSQL/pgvector tenant isolation and CRUD verification.""" from __future__ import annotations import logging import os import uuid from types import SimpleNamespace import pytest import sqlalchemy as sa from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from langbot.pkg.entity.persistence.base import Base from langbot.pkg.entity.persistence.rag import KnowledgeBase from langbot.pkg.entity.persistence.workspace import Workspace from langbot.pkg.persistence.alembic_runner import get_alembic_current, run_alembic_stamp, run_alembic_upgrade from langbot.pkg.persistence.mgr import PersistenceManager, PersistenceMode from langbot.pkg.utils import constants from langbot.pkg.vector.vdbs.pgvector_db import PgVectorDatabase, PgVectorEntry, PgVectorScope pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio] def _application(postgres_url: str, logger_name: str) -> SimpleNamespace: url = sa.engine.make_url(postgres_url) return SimpleNamespace( instance_config=SimpleNamespace( data={ 'database': { 'use': 'postgresql', 'postgresql': { 'host': url.host, 'port': url.port, 'user': url.username, 'password': url.password, 'database': url.database, }, } } ), logger=logging.getLogger(logger_name), ) def _restore_postgres_registry(monkeypatch) -> None: from langbot.pkg.persistence import mgr as persistence_mgr_module from langbot.pkg.persistence.databases.postgresql import PostgreSQLDatabaseManager monkeypatch.setattr( persistence_mgr_module.database, 'preregistered_managers', [PostgreSQLDatabaseManager], ) @pytest.fixture def postgres_url() -> str: url = os.environ.get('TEST_POSTGRES_URL') if not url: pytest.skip('TEST_POSTGRES_URL not set') return url @pytest.fixture async def postgres_engine(postgres_url: str): engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT') yield engine await engine.dispose() @pytest.fixture async def clean_database(postgres_engine: AsyncEngine): async def clean() -> None: async with postgres_engine.begin() as conn: await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors_legacy_0013 CASCADE')) await conn.execute(text('DROP TABLE IF EXISTS langbot_vectors CASCADE')) await conn.run_sync(Base.metadata.drop_all) await conn.execute(text('DROP TABLE IF EXISTS alembic_version')) await clean() yield await clean() async def test_legacy_upgrade_temporarily_suspends_and_restores_source_rls_for_unprivileged_owner( postgres_url: str, postgres_engine: AsyncEngine, clean_database, ) -> None: workspace_uuid = '30000000-0000-0000-0000-000000000303' knowledge_base_uuid = 'legacy-knowledge-base' migrator_role = f'lb_vector_migrator_{uuid.uuid4().hex[:12]}' migrator_password = f'Lb{uuid.uuid4().hex}' quote = postgres_engine.dialect.identifier_preparer.quote database_name = sa.engine.make_url(postgres_url).database migrator_url = ( sa.engine.make_url(postgres_url) .set(username=migrator_role, password=migrator_password) .render_as_string(hide_password=False) ) migrator_engine: AsyncEngine | None = None role_created = False admin_role: str | None = None source_tables = ('knowledge_bases', 'knowledge_base_files', 'knowledge_base_chunks') async def read_rls_states(conn, table_names: tuple[str, ...]) -> dict[str, tuple[bool, bool]]: rows = ( ( await conn.execute( 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 """ ).bindparams(sa.bindparam('table_names', expanding=True)), {'table_names': table_names}, ) ) .mappings() .all() ) return {str(row['relname']): (bool(row['relrowsecurity']), bool(row['relforcerowsecurity'])) for row in rows} try: async with postgres_engine.begin() as conn: admin_role = await conn.scalar(text('SELECT current_user')) await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector')) await conn.run_sync(Base.metadata.create_all) await conn.execute( text( 'ALTER TABLE knowledge_bases DROP CONSTRAINT IF EXISTS ' 'ck_knowledge_bases_embedding_dimension_positive' ) ) await conn.execute(text('ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS embedding_dimension')) await run_alembic_stamp(postgres_engine, '0010_scope_resources') await run_alembic_upgrade(postgres_engine, '0012_plugin_identity') assert await get_alembic_current(postgres_engine) == '0012_plugin_identity' embedding_a = '[' + ','.join(['0.125'] * 384) + ']' embedding_b = '[' + ','.join(['0.25'] * 384) + ']' async with postgres_engine.begin() as conn: await conn.execute( text( """ INSERT INTO workspaces (uuid, instance_uuid, name, slug, type, status, source, projection_revision) VALUES (:uuid, 'legacy-vector-instance', 'legacy', 'legacy-vector', 'team', 'active', 'cloud_projection', 0) """ ), {'uuid': workspace_uuid}, ) await conn.execute( text( """ INSERT INTO knowledge_bases (uuid, workspace_uuid, name, collection_id, legacy_vector_collection) VALUES (:uuid, :workspace_uuid, 'legacy', 'legacy-collection', true) """ ), {'uuid': knowledge_base_uuid, 'workspace_uuid': workspace_uuid}, ) await conn.execute( text( """ INSERT INTO knowledge_base_files (uuid, workspace_uuid, kb_id, file_name, extension, status) VALUES ('legacy-file', :workspace_uuid, :kb_uuid, 'legacy.txt', 'txt', 'completed') """ ), {'workspace_uuid': workspace_uuid, 'kb_uuid': knowledge_base_uuid}, ) await conn.execute( text( """ INSERT INTO knowledge_base_chunks (uuid, workspace_uuid, file_id, text) VALUES ('legacy-chunk', :workspace_uuid, 'legacy-file', 'chunk text') """ ), {'workspace_uuid': workspace_uuid}, ) await conn.execute( text( """ CREATE TABLE langbot_vectors ( id VARCHAR(255) PRIMARY KEY, collection VARCHAR(255), embedding vector NOT NULL, text TEXT, file_id VARCHAR(255), chunk_uuid VARCHAR(255) ) """ ) ) await conn.execute( text( """ INSERT INTO langbot_vectors (id, collection, embedding, text, file_id, chunk_uuid) VALUES ('legacy-by-collection', 'legacy-collection', CAST(:embedding_a AS vector), 'collection row', NULL, NULL), ('legacy-by-chunk', 'unmatched-collection', CAST(:embedding_b AS vector), 'chunk row', NULL, 'legacy-chunk') """ ), {'embedding_a': embedding_a, 'embedding_b': embedding_b}, ) # Exercise exact restoration rather than assuming all source tables # arrived with identical flags. await conn.execute(text('ALTER TABLE knowledge_base_files NO FORCE ROW LEVEL SECURITY')) await conn.execute(text('ALTER TABLE knowledge_base_chunks NO FORCE ROW LEVEL SECURITY')) await conn.execute(text('ALTER TABLE knowledge_base_chunks DISABLE ROW LEVEL SECURITY')) expected_source_rls = await read_rls_states(conn, source_tables) assert expected_source_rls == { 'knowledge_bases': (True, True), 'knowledge_base_files': (True, False), 'knowledge_base_chunks': (False, False), } await conn.execute( text(f"CREATE ROLE {quote(migrator_role)} LOGIN PASSWORD '{migrator_password}' NOSUPERUSER NOBYPASSRLS") ) role_created = True await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(migrator_role)}')) await conn.execute(text(f'GRANT USAGE, CREATE ON SCHEMA public TO {quote(migrator_role)}')) await conn.execute(text(f'GRANT SELECT, UPDATE ON alembic_version TO {quote(migrator_role)}')) for table_name in (*source_tables, 'langbot_vectors'): await conn.execute(text(f'ALTER TABLE {quote(table_name)} OWNER TO {quote(migrator_role)}')) role = ( await conn.execute( text('SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = :role'), {'role': migrator_role}, ) ).one() assert role == (False, False) owned_tables = set( ( await conn.execute( text( """ SELECT c.relname 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 pg_get_userbyid(c.relowner) = :role """ ).bindparams(sa.bindparam('table_names', expanding=True)), {'table_names': (*source_tables, 'langbot_vectors'), 'role': migrator_role}, ) ).scalars() ) assert owned_tables == {*source_tables, 'langbot_vectors'} migrator_engine = create_async_engine(migrator_url) await run_alembic_upgrade(migrator_engine, '0013_tenant_pgvector') assert await get_alembic_current(migrator_engine) == '0013_tenant_pgvector' async with postgres_engine.connect() as conn: migrated_rows = ( ( await conn.execute( text( """ SELECT workspace_uuid, knowledge_base_uuid, vector_id, embedding_dimension, text, file_id, chunk_uuid FROM langbot_vectors ORDER BY vector_id """ ) ) ) .mappings() .all() ) assert [dict(row) for row in migrated_rows] == [ { 'workspace_uuid': workspace_uuid, 'knowledge_base_uuid': knowledge_base_uuid, 'vector_id': 'legacy-by-chunk', 'embedding_dimension': 384, 'text': 'chunk row', 'file_id': None, 'chunk_uuid': 'legacy-chunk', }, { 'workspace_uuid': workspace_uuid, 'knowledge_base_uuid': knowledge_base_uuid, 'vector_id': 'legacy-by-collection', 'embedding_dimension': 384, 'text': 'collection row', 'file_id': None, 'chunk_uuid': None, }, ] assert ( await conn.scalar( text('SELECT embedding_dimension FROM knowledge_bases WHERE uuid = :uuid'), {'uuid': knowledge_base_uuid}, ) == 384 ) assert await conn.scalar(text("SELECT to_regclass('langbot_vectors_legacy_0013') IS NULL")) is True assert await read_rls_states(conn, source_tables) == expected_source_rls assert await read_rls_states(conn, ('langbot_vectors',)) == {'langbot_vectors': (True, True)} assert ( await conn.scalar( text( """ SELECT COUNT(*) FROM pg_policy AS p JOIN pg_class AS c ON c.oid = p.polrelid JOIN pg_namespace AS n ON n.oid = c.relnamespace WHERE n.nspname = current_schema() AND c.relname = 'langbot_vectors' AND p.polname = 'langbot_workspace_isolation' """ ) ) == 1 ) async with migrator_engine.connect() as conn: assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0 async with migrator_engine.begin() as conn: await conn.execute( text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"), {'workspace_uuid': workspace_uuid}, ) assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 2 finally: if migrator_engine is not None: await migrator_engine.dispose() if role_created: async with postgres_engine.connect() as conn: if admin_role is None: # pragma: no cover - setup cannot create the role without an admin admin_role = await conn.scalar(text('SELECT current_user')) await conn.execute(text(f'REASSIGN OWNED BY {quote(migrator_role)} TO {quote(admin_role)}')) await conn.execute(text(f'DROP OWNED BY {quote(migrator_role)}')) await conn.execute(text(f'DROP ROLE IF EXISTS {quote(migrator_role)}')) async def test_pgvector_shared_database_is_scoped_indexed_and_ddl_free_at_runtime( postgres_url: str, postgres_engine: AsyncEngine, clean_database, monkeypatch, ) -> None: instance_uuid = 'pgvector-tenant-integration' workspace_a = '10000000-0000-0000-0000-000000000101' workspace_b = '20000000-0000-0000-0000-000000000202' kb_a = 'knowledge-base-a' kb_b = 'knowledge-base-b' role_suffix = uuid.uuid4().hex[:12] runtime_role = f'lb_vector_runtime_{role_suffix}' role_password = f'Lb{uuid.uuid4().hex}' release_manager: PersistenceManager | None = None runtime_manager: PersistenceManager | None = None role_created = False _restore_postgres_registry(monkeypatch) monkeypatch.setattr(constants, 'instance_id', instance_uuid) quote = postgres_engine.dialect.identifier_preparer.quote database_name = sa.engine.make_url(postgres_url).database runtime_url = ( sa.engine.make_url(postgres_url) .set(username=runtime_role, password=role_password) .render_as_string(hide_password=False) ) try: release_app = _application(postgres_url, 'pgvector-release-migration-test') release_manager = PersistenceManager(release_app, mode=PersistenceMode.RELEASE_MIGRATION) release_app.persistence_mgr = release_manager await release_manager.initialize() for workspace_uuid, kb_uuid, slug in ( (workspace_a, kb_a, 'vector-a'), (workspace_b, kb_b, 'vector-b'), ): async with release_manager.tenant_uow(workspace_uuid) as uow: await uow.execute( sa.insert(Workspace).values( uuid=workspace_uuid, instance_uuid=instance_uuid, name=slug, slug=slug, type='team', status='active', source='cloud_projection', projection_revision=0, ) ) await uow.execute( sa.insert(KnowledgeBase).values( uuid=kb_uuid, workspace_uuid=workspace_uuid, name=slug, embedding_dimension=384, ) ) async with postgres_engine.connect() as conn: await conn.execute(text(f"CREATE ROLE {quote(runtime_role)} LOGIN PASSWORD '{role_password}'")) await conn.execute(text(f'GRANT CONNECT ON DATABASE {quote(database_name)} TO {quote(runtime_role)}')) await conn.execute(text(f'GRANT USAGE ON SCHEMA public TO {quote(runtime_role)}')) business_tables = release_manager._runtime_business_table_names() quoted_tables = ', '.join(f'public.{quote(table_name)}' for table_name in business_tables) await conn.execute( text(f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE {quoted_tables} TO {quote(runtime_role)}') ) await conn.execute(text(f'GRANT SELECT ON TABLE public.alembic_version TO {quote(runtime_role)}')) sequence_names = await release_manager._runtime_business_sequence_names(conn, business_tables) if sequence_names: quoted_sequences = ', '.join(f'public.{quote(sequence_name)}' for sequence_name in sequence_names) await conn.execute(text(f'GRANT USAGE, SELECT ON SEQUENCE {quoted_sequences} TO {quote(runtime_role)}')) role_created = True runtime_app = _application(runtime_url, 'pgvector-runtime-test') runtime_manager = PersistenceManager(runtime_app, mode=PersistenceMode.CLOUD_RUNTIME) runtime_app.persistence_mgr = runtime_manager await runtime_manager.initialize() adapter = PgVectorDatabase( runtime_app, use_business_database=True, allowed_dimensions=[384], ) scope_a = PgVectorScope(workspace_a, kb_a, 384) scope_b = PgVectorScope(workspace_b, kb_b, 384) # The same vector ID is valid in two Workspaces because the relational # primary key includes Workspace and knowledge base. await adapter.add_embeddings( 'opaque-a', ['same-vector'], [[0.1] * 384], [{'text': 'workspace-a', 'file_id': 'file-a', 'uuid': 'chunk-a'}], scope=scope_a, ) await adapter.add_embeddings( 'opaque-b', ['same-vector'], [[0.2] * 384], [{'text': 'workspace-b', 'file_id': 'file-b', 'uuid': 'chunk-b'}], scope=scope_b, ) result_a = await adapter.search('opaque-a', [0.1] * 384, scope=scope_a) result_b = await adapter.search('opaque-b', [0.2] * 384, scope=scope_b) assert result_a['metadatas'][0][0]['text'] == 'workspace-a' assert result_b['metadatas'][0][0]['text'] == 'workspace-b' # Guessing another knowledge-base UUID while retaining A's Workspace # cannot escape either the explicit conditions or PostgreSQL RLS. guessed = await adapter.search( 'attacker-controlled-name', [0.2] * 384, scope=PgVectorScope(workspace_a, kb_b, 384), ) assert guessed['ids'] == [[]] with pytest.raises(ValueError, match='trusted PgVectorScope'): await adapter.search('opaque-a', [0.1] * 384) with pytest.raises(ValueError, match='selected dimension'): await adapter.add_embeddings( 'opaque-a', ['bad-dimension'], [[0.1] * 383], [{}], scope=scope_a, ) # Deliberately omit the application Workspace predicate. FORCE RLS is # still the second isolation boundary and returns only A. async with runtime_manager.tenant_uow(workspace_a) as uow: rows = ( await uow.execute( sa.select(PgVectorEntry.workspace_uuid, PgVectorEntry.vector_id).where( PgVectorEntry.vector_id == 'same-vector' ) ) ).all() assert rows == [(workspace_a, 'same-vector')] # Index-plan inspection is deployment diagnostics, not a public tenant # Session capability. Establish the same transaction-local RLS scope on # a test-only connection and keep raw EXPLAIN outside TenantUnitOfWork. runtime_engine = runtime_manager.get_db_engine() async with runtime_engine.begin() as conn: await conn.execute( text('SELECT set_config(:setting_name, :setting_value, true)'), {'setting_name': 'langbot.workspace_uuid', 'setting_value': workspace_a}, ) await conn.execute(text('SET LOCAL enable_seqscan = off')) plan = '\n'.join( ( await conn.execute( text( """ EXPLAIN SELECT vector_id FROM langbot_vectors WHERE workspace_uuid = :workspace_uuid AND knowledge_base_uuid = :knowledge_base_uuid AND embedding_dimension = 384 ORDER BY (embedding::vector(384)) <=> CAST(:query AS vector(384)) LIMIT 5 """ ), { 'workspace_uuid': workspace_a, 'knowledge_base_uuid': kb_a, 'query': '[' + ','.join(['0.1'] * 384) + ']', }, ) ).scalars() ) assert 'ix_langbot_vectors_hnsw_cosine_384' in plan async with runtime_engine.connect() as conn: assert await conn.scalar(text('SELECT COUNT(*) FROM langbot_vectors')) == 0 assert await conn.scalar(text("SELECT current_setting('langbot.workspace_uuid', true)")) in (None, '') items_a, total_a = await adapter.list_by_filter('opaque-a', scope=scope_a) assert total_a == 1 assert items_a[0]['metadata']['file_id'] == 'file-a' await adapter.delete_by_file_id('opaque-a', 'file-a', scope=scope_a) assert (await adapter.list_by_filter('opaque-a', scope=scope_a))[1] == 0 assert (await adapter.list_by_filter('opaque-b', scope=scope_b))[1] == 1 finally: if runtime_manager is not None and getattr(runtime_manager, 'db', None) is not None: await runtime_manager.get_db_engine().dispose() if release_manager is not None and getattr(release_manager, 'db', None) is not None: await release_manager.get_db_engine().dispose() if role_created: async with postgres_engine.connect() as conn: await conn.execute(text(f'DROP OWNED BY {quote(runtime_role)}')) await conn.execute(text(f'DROP ROLE IF EXISTS {quote(runtime_role)}'))