diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f32ab1f..587f9767 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: jobs: checks: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 @@ -16,4 +17,7 @@ jobs: enable-cache: true - run: uv python install 3.13 - run: make install + - run: make db-up - run: make check + - if: always() + run: make db-down diff --git a/.gitignore b/.gitignore index 213dbd38..35cf062c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ docs/design/2026-07-18-context-engine-design.md .env.* *.local credentials*.json +.context-engine/ # --- Python --- __pycache__/ diff --git a/AGENTS.md b/AGENTS.md index ca4a6665..677d02c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,15 +62,22 @@ make typecheck # strict mypy make test # unit test suite make catalog # static security catalog tests and validation make smoke # API and worker process smoke suite -make check # all required repository checks +make db-up # start the real PostgreSQL 17 + pgvector harness +make db-down # stop the harness while preserving its disposable data volume +make db-reset # destroy and rebuild only the harness's disposable data volume +make integration # real PostgreSQL integration/security harness +make check # all checks; requires make db-up first ``` ## Verification Contract Before claiming an implementation done, run the verified commands recorded -above. Never fabricate output. A green process smoke proves only boot/readiness; -Runtime delivery, database and worker-job capabilities remain `NOT_ACTIVE` until -their owning issues implement and verify them. +above. Never fabricate output. `.context-engine/database.env` is the generated, +ignored, mode-0600 source for local database connection contracts; `compose.yaml` +owns the pinned test service topology. A green process smoke proves only +boot/readiness. The database harness proves PG17/pgvector, role, migration, and +pool-reset infrastructure only; tenant schema, RLS enforcement, Runtime delivery, +and worker-job behavior remain `NOT_ACTIVE` until their owning issues verify them. ## Safety-Rails / Do Not diff --git a/Makefile b/Makefile index 60017d92..9e2dd430 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build lint typecheck test catalog smoke check +.PHONY: install build lint typecheck test catalog smoke db-up db-down db-reset integration check install: uv sync --frozen @@ -22,4 +22,16 @@ catalog: smoke: uv run pytest -q tests/process -check: build lint typecheck test catalog smoke +db-up: + ./scripts/database_harness.sh up + +db-down: + ./scripts/database_harness.sh down + +db-reset: + ./scripts/database_harness.sh reset + +integration: + ./scripts/database_harness.sh integration + +check: build lint typecheck test catalog smoke integration diff --git a/README.md b/README.md index 755722ce..866530b7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ 企业微信),下游把「经过授权、带证据、有预算」的 ContextPackage 交付给 agent 应用与 IM bot(飞书群聊问答优先)。 -**当前状态**:M0 工程骨架已启动。API 和独立 Supply worker 可运行,但 -Runtime delivery、数据库和 worker job 行为仍为 `NOT_ACTIVE`。整体计划见 +**当前状态**:M0 工程骨架已启动。API 和独立 Supply worker 可运行,真实 +PostgreSQL 17 + pgvector 测试底座可复现;Runtime delivery、tenant schema、 +RLS enforcement 和 worker job 行为仍为 `NOT_ACTIVE`。整体计划见 [PLAN.md](./PLAN.md)。 ## 开发命令 @@ -25,9 +26,35 @@ make typecheck # strict mypy make test # 单元测试 make catalog # 安全目录静态测试与校验 make smoke # API / worker 进程 smoke -make check # build + lint + typecheck + test + catalog + smoke +make db-up # 启动真实 PostgreSQL 17 + pgvector 测试底座 +make db-down # 停止测试底座并保留 disposable data volume +make db-reset # 删除并重建该测试底座的 disposable data volume +make integration # 真实 PostgreSQL integration/security harness +make check # 全部门禁;要求先执行 make db-up ``` +数据库底座首次启动时会在被 Git 忽略的 +`.context-engine/database.env` 生成随机凭据并将文件权限设为 `0600`;该文件是 +本地 migration、API Runtime、worker、security test 连接配置和该 checkout +独有 Compose project 身份的唯一实时来源,避免多个 worktree 或 checkout 共享 +容器、网络与数据卷。 +镜像及服务拓扑的版本真相位于 [`compose.yaml`](./compose.yaml),PostgreSQL 只绑定 +一个动态选择的 `127.0.0.1` host port。migration、runtime 与 worker 使用不同 +角色;runtime/security test 不会回退到 migration 或 bootstrap 凭据。 + +从 clean checkout 运行与 CI 相同的数据库门禁: + +```bash +make install +make db-up +make check +make db-down +``` + +`make db-reset` 只删除当前 checkout 的 generated Compose project 所属的 +disposable PostgreSQL volume,然后从初始化脚本重建。它不会删除仓库内容,但会 +清除该本地测试数据库中的全部数据。 + 本地启动 API: ```bash @@ -44,8 +71,9 @@ uv run context-engine-worker --test-mode ``` 健康响应中的 `runtime_delivery: NOT_ACTIVE` 和 worker 输出中的 -`job_behavior: NOT_ACTIVE` 是能力边界,不表示数据库、授权或 ContextPackage -交付已经实现。 +`job_behavior: NOT_ACTIVE` 是能力边界。当前数据库测试只证明 PG17/pgvector、 +角色隔离、空 Alembic baseline 和连接池清理契约;它没有 tenant table,也不声明 +RLS、授权或 ContextPackage 交付已经实现。 本次公开候选 bundle 包含实现权威、ADR、安全契约、PRD、Tech Spec 与四个公开参考仓的证据基线;经维护者批准并提交后,它们将与实现一同 @@ -70,9 +98,9 @@ ContextEngine 的安全协议依据自身需求与威胁模型独立设计,零 - [D0 Baseline Candidate](./DESIGN-BASELINE.md):当前候选状态与尚未关闭的 evidence gates。 -当前只有固定 commit 的四仓静态证据与仓库内设计拆解;PostgreSQL RLS、 -filtered ANN 和飞书 capability 的 disposable evidence spikes 尚未完成, -因此不声称已有动态可行性或产品能力验证。 +当前除固定 commit 的四仓静态证据与仓库内设计拆解外,已有真实 PostgreSQL 17 + +pgvector 的基础 harness 证据。tenant schema / RLS isolation、filtered ANN 和飞书 +capability 的动态证据仍未完成,因此不声称这些产品能力已经验证。 ## 为什么做这个 diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..4fd52538 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = %(here)s/migrations +prepend_sys_path = . +path_separator = os + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..ab9f3b68 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,30 @@ +services: + postgres: + image: pgvector/pgvector:0.8.5-pg17-bookworm@sha256:d2ef61f42ef767baa5a1475393303cc235bcd92febd9d7014eddb48b41f3bad0 + environment: + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + POSTGRES_INITDB_ARGS: --auth-host=scram-sha-256 + CONTEXT_ENGINE_MIGRATOR_ROLE: ${CONTEXT_ENGINE_MIGRATOR_ROLE:?CONTEXT_ENGINE_MIGRATOR_ROLE is required} + CONTEXT_ENGINE_MIGRATOR_PASSWORD: ${CONTEXT_ENGINE_MIGRATOR_PASSWORD:?CONTEXT_ENGINE_MIGRATOR_PASSWORD is required} + CONTEXT_ENGINE_RUNTIME_ROLE: ${CONTEXT_ENGINE_RUNTIME_ROLE:?CONTEXT_ENGINE_RUNTIME_ROLE is required} + CONTEXT_ENGINE_RUNTIME_PASSWORD: ${CONTEXT_ENGINE_RUNTIME_PASSWORD:?CONTEXT_ENGINE_RUNTIME_PASSWORD is required} + CONTEXT_ENGINE_WORKER_ROLE: ${CONTEXT_ENGINE_WORKER_ROLE:?CONTEXT_ENGINE_WORKER_ROLE is required} + CONTEXT_ENGINE_WORKER_PASSWORD: ${CONTEXT_ENGINE_WORKER_PASSWORD:?CONTEXT_ENGINE_WORKER_PASSWORD is required} + ports: + - "127.0.0.1:${CONTEXT_ENGINE_POSTGRES_PORT:?CONTEXT_ENGINE_POSTGRES_PORT is required}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + - ./infra/postgres/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: + - CMD-SHELL + - pg_isready --username "$${POSTGRES_USER}" --dbname "$${POSTGRES_DB}" + interval: 1s + timeout: 5s + retries: 30 + start_period: 5s + +volumes: + postgres-data: diff --git a/engine/persistence/__init__.py b/engine/persistence/__init__.py new file mode 100644 index 00000000..334324a3 --- /dev/null +++ b/engine/persistence/__init__.py @@ -0,0 +1,23 @@ +"""PostgreSQL connectivity owned by the engine.""" + +from engine.persistence.configuration import ( + DatabaseConfiguration, + DatabaseConfigurationError, + DatabasePurpose, + HarnessDatabaseConfigurations, + load_database_configuration, + load_harness_database_configurations, +) +from engine.persistence.database import create_database_engine +from engine.persistence.role_guard import assert_runtime_role + +__all__ = [ + "DatabaseConfiguration", + "DatabaseConfigurationError", + "DatabasePurpose", + "HarnessDatabaseConfigurations", + "assert_runtime_role", + "create_database_engine", + "load_database_configuration", + "load_harness_database_configurations", +] diff --git a/engine/persistence/configuration.py b/engine/persistence/configuration.py new file mode 100644 index 00000000..e83428a4 --- /dev/null +++ b/engine/persistence/configuration.py @@ -0,0 +1,184 @@ +"""Fail-closed database configuration for each process and test purpose.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum + +from sqlalchemy.engine import URL, make_url +from sqlalchemy.exc import ArgumentError + +MIGRATOR_ROLE = "context_engine_migrator" +RUNTIME_ROLE = "context_engine_runtime" +WORKER_ROLE = "context_engine_worker" + + +class DatabasePurpose(Enum): + """A database credential boundary that may never fall back to another role.""" + + MIGRATION = ("CONTEXT_ENGINE_MIGRATION_DATABASE_URL", MIGRATOR_ROLE) + API_RUNTIME = ("CONTEXT_ENGINE_RUNTIME_DATABASE_URL", RUNTIME_ROLE) + SUPPLY_WORKER = ("CONTEXT_ENGINE_WORKER_DATABASE_URL", WORKER_ROLE) + SECURITY_TEST = ("CONTEXT_ENGINE_TEST_DATABASE_URL", RUNTIME_ROLE) + + @property + def environment_variable(self) -> str: + return self.value[0] + + @property + def expected_role(self) -> str: + return self.value[1] + + +ROLE_ENVIRONMENT_VARIABLES: dict[DatabasePurpose, str] = { + DatabasePurpose.MIGRATION: "CONTEXT_ENGINE_MIGRATOR_ROLE", + DatabasePurpose.API_RUNTIME: "CONTEXT_ENGINE_RUNTIME_ROLE", + DatabasePurpose.SUPPLY_WORKER: "CONTEXT_ENGINE_WORKER_ROLE", + DatabasePurpose.SECURITY_TEST: "CONTEXT_ENGINE_RUNTIME_ROLE", +} + + +class DatabaseConfigurationError(ValueError): + """The required role-specific PostgreSQL configuration is absent or unsafe.""" + + +@dataclass(frozen=True) +class DatabaseConfiguration: + """A validated URL paired with the exact role the connection must report.""" + + purpose: DatabasePurpose + url: URL + expected_role: str + + def __post_init__(self) -> None: + if self.expected_role != self.purpose.expected_role: + raise DatabaseConfigurationError( + "database configuration expected role must match its purpose" + ) + if self.url.drivername != "postgresql+psycopg": + raise DatabaseConfigurationError( + "database configuration must use the postgresql+psycopg driver" + ) + if self.url.query: + raise DatabaseConfigurationError( + "database configuration URL must not contain query parameters" + ) + if self.url.username != self.expected_role: + raise DatabaseConfigurationError( + "database configuration URL username must match its expected role" + ) + if self.url.password is None or not str(self.url.password): + raise DatabaseConfigurationError( + "database configuration must contain explicit PostgreSQL credentials" + ) + if not self.url.host or not self.url.database: + raise DatabaseConfigurationError( + "database configuration must contain an explicit host and database" + ) + + def __repr__(self) -> str: + return ( + "DatabaseConfiguration(" + f"purpose={self.purpose.name}, " + f"url={self.url.render_as_string(hide_password=True)!r}, " + f"expected_role={self.expected_role!r})" + ) + + +@dataclass(frozen=True) +class HarnessDatabaseConfigurations: + """All role-isolated URLs required by the authoritative database harness.""" + + migration: DatabaseConfiguration + runtime: DatabaseConfiguration + worker: DatabaseConfiguration + security_test: DatabaseConfiguration + + +def _require_url(variable: str, environment: Mapping[str, str]) -> URL: + raw_url = environment.get(variable) + if raw_url is None or not raw_url.strip(): + raise DatabaseConfigurationError( + f"{variable} is required; database roles never fall back to another URL" + ) + try: + url = make_url(raw_url) + except ArgumentError as error: + raise DatabaseConfigurationError( + f"{variable} must be a valid SQLAlchemy URL" + ) from error + if url.drivername != "postgresql+psycopg": + raise DatabaseConfigurationError( + f"{variable} must use the postgresql+psycopg driver" + ) + if url.query: + raise DatabaseConfigurationError( + f"{variable} must not contain query parameters" + ) + if not url.username or url.password is None or not str(url.password): + raise DatabaseConfigurationError( + f"{variable} must contain explicit PostgreSQL credentials" + ) + if not url.host or not url.database: + raise DatabaseConfigurationError( + f"{variable} must contain an explicit host and database" + ) + return url + + +def load_database_configuration( + purpose: DatabasePurpose, + environment: Mapping[str, str] | None = None, +) -> DatabaseConfiguration: + """Load exactly one role URL without any privileged or cross-process fallback.""" + + source = os.environ if environment is None else environment + role_variable = ROLE_ENVIRONMENT_VARIABLES[purpose] + configured_role = source.get(role_variable) + if configured_role != purpose.expected_role: + raise DatabaseConfigurationError( + f"{role_variable} must be {purpose.expected_role!r}" + ) + url = _require_url(purpose.environment_variable, source) + if url.username != purpose.expected_role: + raise DatabaseConfigurationError( + f"{purpose.environment_variable} expected database role " + f"{purpose.expected_role!r}" + ) + return DatabaseConfiguration( + purpose=purpose, + url=url, + expected_role=purpose.expected_role, + ) + + +def load_harness_database_configurations( + environment: Mapping[str, str] | None = None, +) -> HarnessDatabaseConfigurations: + """Load and cross-check the four explicit harness credential contracts.""" + + source = os.environ if environment is None else environment + configurations = HarnessDatabaseConfigurations( + migration=load_database_configuration(DatabasePurpose.MIGRATION, source), + runtime=load_database_configuration(DatabasePurpose.API_RUNTIME, source), + worker=load_database_configuration(DatabasePurpose.SUPPLY_WORKER, source), + security_test=load_database_configuration( + DatabasePurpose.SECURITY_TEST, source + ), + ) + distinct_roles = { + configurations.migration.expected_role, + configurations.runtime.expected_role, + configurations.worker.expected_role, + } + if len(distinct_roles) != 3: + raise DatabaseConfigurationError( + "migration, runtime, and worker database roles must be distinct" + ) + if configurations.security_test.url != configurations.runtime.url: + raise DatabaseConfigurationError( + "CONTEXT_ENGINE_TEST_DATABASE_URL must exactly equal the runtime URL" + ) + return configurations diff --git a/engine/persistence/database.py b/engine/persistence/database.py new file mode 100644 index 00000000..e34ae2a2 --- /dev/null +++ b/engine/persistence/database.py @@ -0,0 +1,75 @@ +"""SQLAlchemy engine construction with explicit pooled-session cleanup.""" + +from __future__ import annotations + +from typing import Any + +from psycopg.pq import TransactionStatus +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine +from sqlalchemy.exc import DisconnectionError +from sqlalchemy.pool import PoolResetState + +from engine.persistence.configuration import DatabaseConfiguration + + +def _reset_session_state( + dbapi_connection: Any, + _connection_record: Any, + reset_state: PoolResetState, +) -> None: + """Remove reusable session state without invalidating psycopg's cache.""" + + if reset_state.terminate_only: + return + dbapi_connection.rollback() + if dbapi_connection.info.transaction_status is not TransactionStatus.IDLE: + raise DisconnectionError( + "pooled PostgreSQL connection could not be rolled back before reset" + ) + previous_autocommit = dbapi_connection.autocommit + try: + dbapi_connection.autocommit = True + with dbapi_connection.cursor() as cursor: + cursor.execute("CLOSE ALL") + cursor.execute("RESET ROLE") + cursor.execute("RESET SESSION AUTHORIZATION") + cursor.execute("RESET ALL") + cursor.execute("UNLISTEN *") + cursor.execute("SELECT pg_advisory_unlock_all()") + cursor.execute("DISCARD TEMP") + finally: + dbapi_connection.autocommit = previous_autocommit + + +def _reject_dirty_checkout( + dbapi_connection: Any, + _connection_record: Any, + _connection_proxy: Any, +) -> None: + """Invalidate a pooled connection if transaction state survived check-in.""" + + if dbapi_connection.info.transaction_status is not TransactionStatus.IDLE: + raise DisconnectionError( + "pooled PostgreSQL connection was not idle at checkout" + ) + + +def create_database_engine( + configuration: DatabaseConfiguration, + *, + pool_size: int = 5, + max_overflow: int = 10, +) -> Engine: + """Build a PostgreSQL engine whose pool reset point clears every session GUC.""" + + options: dict[str, object] = { + "pool_pre_ping": True, + "pool_reset_on_return": None, + "pool_size": pool_size, + "max_overflow": max_overflow, + } + engine = create_engine(configuration.url, **options) + event.listen(engine.pool, "reset", _reset_session_state) + event.listen(engine.pool, "checkout", _reject_dirty_checkout) + return engine diff --git a/engine/persistence/role_guard.py b/engine/persistence/role_guard.py new file mode 100644 index 00000000..d7eede6f --- /dev/null +++ b/engine/persistence/role_guard.py @@ -0,0 +1,91 @@ +"""Catalog-backed role guard for authoritative PostgreSQL security tests.""" + +from __future__ import annotations + +from sqlalchemy import Connection, text + +from engine.persistence.configuration import MIGRATOR_ROLE, RUNTIME_ROLE + + +def assert_runtime_role(connection: Connection) -> None: + """Reject owner, superuser, BYPASSRLS, inheriting, or CREATE-capable sessions.""" + + row = connection.execute( + text( + """ + SELECT + current_user AS current_role, + session_user AS session_role, + role.rolsuper AS is_superuser, + role.rolbypassrls AS bypasses_rls, + role.rolinherit AS inherits_roles, + role.rolcreaterole AS can_create_roles, + role.rolcreatedb AS can_create_databases, + role.rolreplication AS can_replicate, + NOT EXISTS ( + SELECT 1 + FROM pg_auth_members AS membership + WHERE membership.member = role.oid + ) AS has_no_role_memberships, + pg_has_role(current_user, :migrator_role, 'MEMBER') + AS is_migrator_member, + pg_has_role(current_user, :migrator_role, 'USAGE') + AS can_use_migrator, + pg_get_userbyid(database.datdba) = current_user + AS owns_database, + pg_get_userbyid(namespace.nspowner) = current_user + AS owns_public_schema, + NOT EXISTS ( + SELECT 1 + FROM pg_class AS relation + JOIN pg_namespace AS relation_namespace + ON relation_namespace.oid = relation.relnamespace + WHERE relation_namespace.nspname = 'public' + AND relation.relkind IN ('r', 'p', 'v', 'm', 'S', 'f') + AND relation.relowner = role.oid + ) AS owns_no_public_relations, + has_database_privilege(current_user, current_database(), 'CREATE') + AS can_create_in_database, + has_database_privilege( + current_user, current_database(), 'TEMPORARY' + ) AS can_create_temporary_tables, + has_schema_privilege(current_user, 'public', 'CREATE') + AS can_create_in_public_schema + FROM pg_roles AS role + JOIN pg_database AS database + ON database.datname = current_database() + JOIN pg_namespace AS namespace + ON namespace.nspname = 'public' + WHERE role.rolname = current_user + """ + ), + {"migrator_role": MIGRATOR_ROLE}, + ).mappings().one() + expected = { + "current_role": RUNTIME_ROLE, + "session_role": RUNTIME_ROLE, + "is_superuser": False, + "bypasses_rls": False, + "inherits_roles": False, + "can_create_roles": False, + "can_create_databases": False, + "can_replicate": False, + "has_no_role_memberships": True, + "is_migrator_member": False, + "can_use_migrator": False, + "owns_database": False, + "owns_public_schema": False, + "owns_no_public_relations": True, + "can_create_in_database": False, + "can_create_temporary_tables": False, + "can_create_in_public_schema": False, + } + observed = dict(row) + if observed != expected: + raise AssertionError( + "PostgreSQL security integration tests require the exact non-owner " + "runtime role with NOSUPERUSER, NOBYPASSRLS, NOINHERIT, NOCREATEROLE, " + "NOCREATEDB, NOREPLICATION, no role memberships, no object ownership, " + "and no database CREATE/TEMPORARY or schema CREATE privilege " + f"(observed={observed!r}, expected={expected!r})" + ) diff --git a/infra/postgres/init/10-security-roles.sh b/infra/postgres/init/10-security-roles.sh new file mode 100755 index 00000000..87428465 --- /dev/null +++ b/infra/postgres/init/10-security-roles.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +required_environment=( + POSTGRES_DB + POSTGRES_USER + CONTEXT_ENGINE_MIGRATOR_ROLE + CONTEXT_ENGINE_MIGRATOR_PASSWORD + CONTEXT_ENGINE_RUNTIME_ROLE + CONTEXT_ENGINE_RUNTIME_PASSWORD + CONTEXT_ENGINE_WORKER_ROLE + CONTEXT_ENGINE_WORKER_PASSWORD +) + +for variable_name in "${required_environment[@]}"; do + if [[ -z "${!variable_name:-}" ]]; then + printf 'required database bootstrap variable is missing: %s\n' \ + "$variable_name" >&2 + exit 1 + fi +done + +psql \ + --set=ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <<'SQL' +\getenv database_name POSTGRES_DB +\getenv migrator_role CONTEXT_ENGINE_MIGRATOR_ROLE +\getenv migrator_password CONTEXT_ENGINE_MIGRATOR_PASSWORD +\getenv runtime_role CONTEXT_ENGINE_RUNTIME_ROLE +\getenv runtime_password CONTEXT_ENGINE_RUNTIME_PASSWORD +\getenv worker_role CONTEXT_ENGINE_WORKER_ROLE +\getenv worker_password CONTEXT_ENGINE_WORKER_PASSWORD + +CREATE ROLE :"migrator_role" + LOGIN + PASSWORD :'migrator_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +CREATE ROLE :"runtime_role" + LOGIN + PASSWORD :'runtime_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +CREATE ROLE :"worker_role" + LOGIN + PASSWORD :'worker_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOINHERIT + NOREPLICATION + NOBYPASSRLS; + +REVOKE ALL ON DATABASE :"database_name" FROM PUBLIC; +GRANT CONNECT ON DATABASE :"database_name" + TO :"migrator_role", :"runtime_role", :"worker_role"; +ALTER DATABASE :"database_name" OWNER TO :"migrator_role"; + +REVOKE ALL ON SCHEMA public FROM PUBLIC; +ALTER SCHEMA public OWNER TO :"migrator_role"; +GRANT USAGE ON SCHEMA public TO :"runtime_role", :"worker_role"; + +-- pgvector is an untrusted extension, so only the disposable bootstrap +-- superuser creates it. Application schema objects remain migrator-owned. +CREATE EXTENSION vector WITH SCHEMA public; +SQL diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..4f930da9 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,59 @@ +"""Alembic environment bound exclusively to the migration-role URL.""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from engine.persistence.configuration import ( + DatabasePurpose, + load_database_configuration, +) + +configuration = context.config +if configuration.config_file_name is not None: + fileConfig(configuration.config_file_name) + +target_metadata = None + + +def _migration_section() -> dict[str, str]: + section = configuration.get_section(configuration.config_ini_section) or {} + database = load_database_configuration(DatabasePurpose.MIGRATION) + section["sqlalchemy.url"] = database.url.render_as_string(hide_password=False) + return section + + +def run_migrations_offline() -> None: + """Configure offline SQL generation without embedding a repository URL.""" + + context.configure( + url=_migration_section()["sqlalchemy.url"], + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations through the reviewed migrator and a one-shot pool.""" + + connectable = engine_from_config( + _migration_section(), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..e848d9e4 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/20260720_0001_empty_baseline.py b/migrations/versions/20260720_0001_empty_baseline.py new file mode 100644 index 00000000..916c4f15 --- /dev/null +++ b/migrations/versions/20260720_0001_empty_baseline.py @@ -0,0 +1,24 @@ +"""Establish the empty PostgreSQL harness migration baseline. + +Revision ID: 20260720_0001 +Revises: None +Create Date: 2026-07-20 + +The Alembic version table is the only relation created. Organization, +Membership, tenant tables, and RLS policies belong to later owning issues. +""" + +from collections.abc import Sequence + +revision: str = "20260720_0001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Keep the application schema empty at the first migration revision.""" + + +def downgrade() -> None: + """Return to Alembic base without changing shared extensions or roles.""" diff --git a/pyproject.toml b/pyproject.toml index 2777c398..223132ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,10 @@ description = "Permission-aware context delivery engine" readme = "README.md" requires-python = ">=3.13,<3.14" dependencies = [ + "alembic>=1.16,<1.17", "fastapi>=0.116,<0.117", + "psycopg[binary]>=3.2,<3.3", + "sqlalchemy>=2.0,<2.1", "uvicorn>=0.35,<0.36", ] @@ -31,10 +34,20 @@ packages = ["engine", "adapters", "applications"] [tool.mypy] python_version = "3.13" strict = true -files = ["engine", "adapters", "applications", "tests"] +files = [ + "engine", + "adapters", + "applications", + "migrations", + "scripts/wait_for_database.py", + "tests", +] [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "integration: requires the real PostgreSQL 17 + pgvector harness", +] [tool.ruff] target-version = "py313" diff --git a/scripts/database_harness.sh b/scripts/database_harness.sh new file mode 100755 index 00000000..f05ef897 --- /dev/null +++ b/scripts/database_harness.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly ROOT_DIR +readonly STATE_DIR="$ROOT_DIR/.context-engine" +readonly ENV_FILE="$STATE_DIR/database.env" +readonly LEGACY_PROJECT_FILE="$STATE_DIR/compose-project" +readonly COMPOSE_FILE="$ROOT_DIR/compose.yaml" +COMPOSE_PROJECT='' + +usage() { + printf 'usage: %s {up|down|reset|integration}\n' "$0" >&2 +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'required command is unavailable: %s\n' "$1" >&2 + exit 1 + fi +} + +generate_environment() { + if [[ -L "$STATE_DIR" ]]; then + printf 'refusing to use a symbolic-link database state directory: %s\n' \ + "$STATE_DIR" >&2 + exit 1 + fi + if [[ -L "$ENV_FILE" ]]; then + printf 'refusing to use a symbolic-link database environment: %s\n' \ + "$ENV_FILE" >&2 + exit 1 + fi + + require_command python3 + mkdir -p "$STATE_DIR" + chmod 700 "$STATE_DIR" + + if [[ ! -f "$ENV_FILE" ]]; then + local bootstrap_password + local migrator_password + local runtime_password + local worker_password + local postgres_port + local compose_project + bootstrap_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + migrator_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + runtime_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + worker_password="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + postgres_port="$(python3 -c 'import socket; s = socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + compose_project="context-engine-$(python3 -c \ + 'import secrets; print(secrets.token_hex(8))')" + + local temporary_file + temporary_file="$(mktemp "$STATE_DIR/database.env.tmp.XXXXXX")" + trap 'rm -f "$temporary_file"' EXIT + ( + umask 077 + { + printf 'POSTGRES_DB=context_engine\n' + printf 'POSTGRES_USER=context_engine_bootstrap\n' + printf 'POSTGRES_PASSWORD=%s\n' "$bootstrap_password" + printf 'CONTEXT_ENGINE_POSTGRES_PORT=%s\n' "$postgres_port" + printf 'CONTEXT_ENGINE_COMPOSE_PROJECT=%s\n' "$compose_project" + printf 'CONTEXT_ENGINE_MIGRATOR_ROLE=context_engine_migrator\n' + printf 'CONTEXT_ENGINE_MIGRATOR_PASSWORD=%s\n' "$migrator_password" + printf 'CONTEXT_ENGINE_RUNTIME_ROLE=context_engine_runtime\n' + printf 'CONTEXT_ENGINE_RUNTIME_PASSWORD=%s\n' "$runtime_password" + printf 'CONTEXT_ENGINE_WORKER_ROLE=context_engine_worker\n' + printf 'CONTEXT_ENGINE_WORKER_PASSWORD=%s\n' "$worker_password" + printf 'CONTEXT_ENGINE_MIGRATION_DATABASE_URL=postgresql+psycopg://context_engine_migrator:%s@127.0.0.1:%s/context_engine\n' \ + "$migrator_password" "$postgres_port" + printf 'CONTEXT_ENGINE_RUNTIME_DATABASE_URL=postgresql+psycopg://context_engine_runtime:%s@127.0.0.1:%s/context_engine\n' \ + "$runtime_password" "$postgres_port" + printf 'CONTEXT_ENGINE_WORKER_DATABASE_URL=postgresql+psycopg://context_engine_worker:%s@127.0.0.1:%s/context_engine\n' \ + "$worker_password" "$postgres_port" + printf 'CONTEXT_ENGINE_TEST_DATABASE_URL=postgresql+psycopg://context_engine_runtime:%s@127.0.0.1:%s/context_engine\n' \ + "$runtime_password" "$postgres_port" + } >"$temporary_file" + ) + chmod 600 "$temporary_file" + if ! ln "$temporary_file" "$ENV_FILE" 2>/dev/null && \ + [[ ! -f "$ENV_FILE" || -L "$ENV_FILE" ]]; then + printf 'could not publish database environment atomically: %s\n' \ + "$ENV_FILE" >&2 + exit 1 + fi + rm -f "$temporary_file" + trap - EXIT + fi + + if [[ -L "$ENV_FILE" || ! -O "$ENV_FILE" ]]; then + printf 'database environment must be a current-user-owned regular file: %s\n' \ + "$ENV_FILE" >&2 + exit 1 + fi + chmod 600 "$ENV_FILE" + migrate_legacy_project_identity +} + +read_embedded_project_identity() { + local variable_name + local variable_value + while IFS='=' read -r variable_name variable_value; do + if [[ "$variable_name" == 'CONTEXT_ENGINE_COMPOSE_PROJECT' ]]; then + printf '%s\n' "$variable_value" + return 0 + fi + done <"$ENV_FILE" + return 1 +} + +migrate_legacy_project_identity() { + if read_embedded_project_identity >/dev/null; then + return + fi + if [[ ! -e "$LEGACY_PROJECT_FILE" && ! -L "$LEGACY_PROJECT_FILE" ]]; then + return + fi + if [[ -L "$LEGACY_PROJECT_FILE" || ! -f "$LEGACY_PROJECT_FILE" || \ + ! -O "$LEGACY_PROJECT_FILE" ]]; then + printf 'legacy Compose project identity must be a current-user-owned regular file: %s\n' \ + "$LEGACY_PROJECT_FILE" >&2 + exit 1 + fi + + local legacy_project + legacy_project="$(<"$LEGACY_PROJECT_FILE")" + if [[ ! "$legacy_project" =~ ^context-engine-[0-9a-f]{16}$ ]]; then + printf 'legacy Compose project identity failed its generated-value contract\n' >&2 + exit 1 + fi + + local migration_file + migration_file="$(mktemp "$STATE_DIR/database.env.migrate.XXXXXX")" + trap 'rm -f "$migration_file"' EXIT + ( + umask 077 + while IFS= read -r environment_line || [[ -n "$environment_line" ]]; do + printf '%s\n' "$environment_line" + done <"$ENV_FILE" + printf 'CONTEXT_ENGINE_COMPOSE_PROJECT=%s\n' "$legacy_project" + ) >"$migration_file" + chmod 600 "$migration_file" + mv "$migration_file" "$ENV_FILE" + trap - EXIT +} + +load_environment() { + generate_environment + + local variable_name + local variable_value + local loaded_variable_names=' ' + local allowed_variables=' POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD CONTEXT_ENGINE_POSTGRES_PORT CONTEXT_ENGINE_COMPOSE_PROJECT CONTEXT_ENGINE_MIGRATOR_ROLE CONTEXT_ENGINE_MIGRATOR_PASSWORD CONTEXT_ENGINE_RUNTIME_ROLE CONTEXT_ENGINE_RUNTIME_PASSWORD CONTEXT_ENGINE_WORKER_ROLE CONTEXT_ENGINE_WORKER_PASSWORD CONTEXT_ENGINE_MIGRATION_DATABASE_URL CONTEXT_ENGINE_RUNTIME_DATABASE_URL CONTEXT_ENGINE_WORKER_DATABASE_URL CONTEXT_ENGINE_TEST_DATABASE_URL ' + + while IFS='=' read -r variable_name variable_value; do + if [[ -z "$variable_name" || "$allowed_variables" != *" $variable_name "* ]]; then + printf 'database environment contains an unexpected variable: %s\n' \ + "$variable_name" >&2 + exit 1 + fi + if [[ "$loaded_variable_names" == *" $variable_name "* ]]; then + printf 'database environment contains a duplicate variable: %s\n' \ + "$variable_name" >&2 + exit 1 + fi + loaded_variable_names+="$variable_name " + export "$variable_name=$variable_value" + done <"$ENV_FILE" + + local required_variable + for required_variable in ${allowed_variables}; do + if [[ "$loaded_variable_names" != *" $required_variable "* ]]; then + printf 'database environment is missing variable: %s\n' \ + "$required_variable" >&2 + exit 1 + fi + done + + if [[ "$POSTGRES_DB" != 'context_engine' || \ + "$POSTGRES_USER" != 'context_engine_bootstrap' || \ + "$CONTEXT_ENGINE_MIGRATOR_ROLE" != 'context_engine_migrator' || \ + "$CONTEXT_ENGINE_RUNTIME_ROLE" != 'context_engine_runtime' || \ + "$CONTEXT_ENGINE_WORKER_ROLE" != 'context_engine_worker' || \ + ! "$CONTEXT_ENGINE_POSTGRES_PORT" =~ ^[0-9]+$ || \ + ! "$CONTEXT_ENGINE_COMPOSE_PROJECT" =~ ^context-engine-[0-9a-f]{16}$ || \ + ! "$POSTGRES_PASSWORD" =~ ^[0-9a-f]{64}$ || \ + ! "$CONTEXT_ENGINE_MIGRATOR_PASSWORD" =~ ^[0-9a-f]{64}$ || \ + ! "$CONTEXT_ENGINE_RUNTIME_PASSWORD" =~ ^[0-9a-f]{64}$ || \ + ! "$CONTEXT_ENGINE_WORKER_PASSWORD" =~ ^[0-9a-f]{64}$ ]]; then + printf 'database environment failed its generated-value contract\n' >&2 + exit 1 + fi + COMPOSE_PROJECT="$CONTEXT_ENGINE_COMPOSE_PROJECT" + + local database_endpoint="127.0.0.1:$CONTEXT_ENGINE_POSTGRES_PORT/context_engine" + if [[ "$CONTEXT_ENGINE_MIGRATION_DATABASE_URL" != \ + "postgresql+psycopg://context_engine_migrator:$CONTEXT_ENGINE_MIGRATOR_PASSWORD@$database_endpoint" || \ + "$CONTEXT_ENGINE_RUNTIME_DATABASE_URL" != \ + "postgresql+psycopg://context_engine_runtime:$CONTEXT_ENGINE_RUNTIME_PASSWORD@$database_endpoint" || \ + "$CONTEXT_ENGINE_WORKER_DATABASE_URL" != \ + "postgresql+psycopg://context_engine_worker:$CONTEXT_ENGINE_WORKER_PASSWORD@$database_endpoint" || \ + "$CONTEXT_ENGINE_TEST_DATABASE_URL" != \ + "$CONTEXT_ENGINE_RUNTIME_DATABASE_URL" ]]; then + printf 'database environment failed its role-isolated URL contract\n' >&2 + exit 1 + fi +} + +compose() { + docker compose \ + --project-name "$COMPOSE_PROJECT" \ + --env-file "$ENV_FILE" \ + --file "$COMPOSE_FILE" \ + "$@" +} + +wait_for_database() { + uv run python "$ROOT_DIR/scripts/wait_for_database.py" +} + +database_up() { + require_command docker + require_command uv + load_environment + compose up --detach --wait + wait_for_database + printf 'PostgreSQL harness is ready; connection contract: %s\n' "$ENV_FILE" +} + +database_down() { + require_command docker + if [[ ! -f "$ENV_FILE" ]]; then + printf 'PostgreSQL harness has no generated state; nothing to stop.\n' + return + fi + load_environment + compose down --remove-orphans +} + +database_reset() { + require_command docker + require_command uv + load_environment + compose down --volumes --remove-orphans + compose up --detach --wait + wait_for_database + printf 'PostgreSQL harness was rebuilt from an empty data volume.\n' +} + +run_integration() { + require_command uv + load_environment + wait_for_database + uv run pytest -q -m integration tests/integration +} + +if [[ $# -ne 1 ]]; then + usage + exit 2 +fi + +case "$1" in + up) + database_up + ;; + down) + database_down + ;; + reset) + database_reset + ;; + integration) + run_integration + ;; + *) + usage + exit 2 + ;; +esac diff --git a/scripts/wait_for_database.py b/scripts/wait_for_database.py new file mode 100644 index 00000000..9abf7d9e --- /dev/null +++ b/scripts/wait_for_database.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Wait until every role-isolated PostgreSQL harness connection is usable.""" + +from __future__ import annotations + +import argparse +import time +from collections.abc import Sequence + +from sqlalchemy import Engine, text +from sqlalchemy.exc import SQLAlchemyError + +from engine.persistence import ( + create_database_engine, + load_harness_database_configurations, +) + + +def wait_for_database(timeout_seconds: float) -> None: + """Probe migration, runtime, worker, and security-test URLs without fallback.""" + + configurations = load_harness_database_configurations() + deadline = time.monotonic() + timeout_seconds + last_error: SQLAlchemyError | None = None + while time.monotonic() < deadline: + engines: list[Engine] = [] + try: + for configuration in ( + configurations.migration, + configurations.runtime, + configurations.worker, + configurations.security_test, + ): + engine = create_database_engine(configuration) + engines.append(engine) + with engine.connect() as connection: + current_role = connection.execute( + text("SELECT current_user") + ).scalar_one() + if current_role != configuration.expected_role: + raise RuntimeError( + "database connection reported an unexpected role" + ) + return + except SQLAlchemyError as error: + last_error = error + time.sleep(0.25) + finally: + for engine in engines: + engine.dispose() + raise TimeoutError( + "PostgreSQL harness did not become ready before the configured timeout" + ) from last_error + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--timeout", type=float, default=30.0) + arguments = parser.parse_args(argv) + wait_for_database(arguments.timeout) + print("PostgreSQL harness ready: migration, runtime, worker, security-test") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 00000000..f51e39ae --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import Engine + +from engine.persistence import ( + DatabaseConfiguration, + HarnessDatabaseConfigurations, + assert_runtime_role, + create_database_engine, + load_harness_database_configurations, +) + +ROOT = Path(__file__).parents[2] + + +@pytest.fixture(scope="session") +def database_configurations() -> HarnessDatabaseConfigurations: + """Load the role-isolated URLs; missing harness state is a hard failure.""" + + return load_harness_database_configurations() + + +@pytest.fixture(scope="session") +def runtime_configuration( + database_configurations: HarnessDatabaseConfigurations, +) -> DatabaseConfiguration: + return database_configurations.security_test + + +@pytest.fixture(scope="session") +def migration_configuration( + database_configurations: HarnessDatabaseConfigurations, +) -> DatabaseConfiguration: + return database_configurations.migration + + +@pytest.fixture(scope="session") +def worker_configuration( + database_configurations: HarnessDatabaseConfigurations, +) -> DatabaseConfiguration: + return database_configurations.worker + + +@pytest.fixture(scope="session", autouse=True) +def guarded_runtime_engine( + runtime_configuration: DatabaseConfiguration, +) -> Iterator[Engine]: + """Guard every integration run against owner/superuser credential fallback.""" + + engine = create_database_engine(runtime_configuration) + try: + with engine.connect() as connection: + assert_runtime_role(connection) + yield engine + finally: + engine.dispose() + + +@pytest.fixture(scope="session", autouse=True) +def migrated_database(guarded_runtime_engine: Engine) -> Iterator[None]: + """Put the disposable database at the empty baseline revision for all tests.""" + + configuration = Config(ROOT / "alembic.ini") + command.upgrade(configuration, "head") + yield diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py new file mode 100644 index 00000000..6bf3121f --- /dev/null +++ b/tests/integration/test_migrations.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import text + +from engine.persistence import DatabaseConfiguration, create_database_engine + +pytestmark = pytest.mark.integration +ROOT = Path(__file__).parents[2] + + +def _revision_rows(configuration: DatabaseConfiguration) -> list[str]: + engine = create_database_engine(configuration) + try: + with engine.connect() as connection: + return list( + connection.execute( + text("SELECT version_num FROM alembic_version") + ).scalars() + ) + finally: + engine.dispose() + + +def test_empty_baseline_downgrade_upgrade_cycle( + migration_configuration: DatabaseConfiguration, +) -> None: + alembic_configuration = Config(ROOT / "alembic.ini") + + try: + command.downgrade(alembic_configuration, "base") + assert _revision_rows(migration_configuration) == [] + finally: + command.upgrade(alembic_configuration, "head") + assert _revision_rows(migration_configuration) == ["20260720_0001"] + + +def test_baseline_contains_no_application_or_tenant_tables( + migration_configuration: DatabaseConfiguration, +) -> None: + engine = create_database_engine(migration_configuration) + try: + with engine.connect() as connection: + tables = list( + connection.execute( + text( + """ + SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename + """ + ) + ).scalars() + ) + rls_tables = connection.execute( + text( + """ + SELECT count(*) + FROM pg_class AS relation + JOIN pg_namespace AS namespace + ON namespace.oid = relation.relnamespace + WHERE namespace.nspname = 'public' + AND relation.relkind IN ('r', 'p') + AND (relation.relrowsecurity OR relation.relforcerowsecurity) + """ + ) + ).scalar_one() + finally: + engine.dispose() + + assert tables == ["alembic_version"] + assert rls_tables == 0 diff --git a/tests/integration/test_pool_reset.py b/tests/integration/test_pool_reset.py new file mode 100644 index 00000000..52a631f4 --- /dev/null +++ b/tests/integration/test_pool_reset.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest +from sqlalchemy import Engine, text + +from engine.persistence import DatabaseConfiguration, create_database_engine + +pytestmark = pytest.mark.integration + + +def setting(engine: Engine, name: str) -> str | None: + with engine.connect() as connection: + return connection.execute( + text("SELECT current_setting(:name, true)"), {"name": name} + ).scalar_one_or_none() + + +def test_pool_reset_discards_session_state_before_connection_reuse( + runtime_configuration: DatabaseConfiguration, +) -> None: + engine = create_database_engine( + runtime_configuration, + pool_size=1, + max_overflow=0, + ) + marker = "context_engine.harness_session_marker" + try: + with engine.connect() as connection: + backend_pid = connection.execute(text("SELECT pg_backend_pid()")) + first_backend_pid = backend_pid.scalar_one() + connection.execute( + text("SELECT set_config(:name, 'org-a', false)"), {"name": marker} + ) + connection.execute(text("LISTEN harness_pool_poison")) + assert connection.execute( + text("SELECT pg_try_advisory_lock(7007)") + ).scalar_one() is True + connection.commit() + assert connection.execute( + text("SELECT current_setting(:name, true)"), {"name": marker} + ).scalar_one() == "org-a" + + with engine.connect() as connection: + assert ( + connection.execute(text("SELECT pg_backend_pid()")).scalar_one() + == first_backend_pid + ) + assert connection.execute( + text("SELECT current_setting(:name, true)"), {"name": marker} + ).scalar_one_or_none() in {None, ""} + assert list( + connection.execute(text("SELECT * FROM pg_listening_channels()")) + ) == [] + advisory_locks = connection.execute( + text( + """ + SELECT count(*) + FROM pg_locks + WHERE locktype = 'advisory' + AND pid = pg_backend_pid() + """ + ) + ).scalar_one() + assert advisory_locks == 0 + finally: + engine.dispose() + + +def test_pool_reset_preserves_driver_prepared_statement_consistency( + runtime_configuration: DatabaseConfiguration, +) -> None: + engine = create_database_engine( + runtime_configuration, + pool_size=1, + max_overflow=0, + ) + statement = text("SELECT CAST(:value AS integer)") + try: + with engine.connect() as connection: + for value in range(8): + assert connection.execute( + statement, {"value": value} + ).scalar_one() == value + connection.commit() + prepared_before_reset = connection.execute( + text("SELECT count(*) FROM pg_prepared_statements") + ).scalar_one() + assert prepared_before_reset > 0 + + with engine.connect() as connection: + assert connection.execute(statement, {"value": 9}).scalar_one() == 9 + finally: + engine.dispose() + + +@pytest.mark.parametrize("outcome", ["commit", "rollback"]) +def test_transaction_local_context_is_gone_after_transaction_end( + runtime_configuration: DatabaseConfiguration, + outcome: str, +) -> None: + engine = create_database_engine( + runtime_configuration, + pool_size=1, + max_overflow=0, + ) + marker = "context_engine.harness_transaction_marker" + try: + with engine.connect() as connection: + transaction = connection.begin() + connection.execute( + text("SELECT set_config(:name, 'org-a', true)"), {"name": marker} + ) + assert connection.execute( + text("SELECT current_setting(:name, true)"), {"name": marker} + ).scalar_one() == "org-a" + if outcome == "commit": + transaction.commit() + else: + transaction.rollback() + + assert setting(engine, marker) in {None, ""} + finally: + engine.dispose() diff --git a/tests/integration/test_postgres_harness.py b/tests/integration/test_postgres_harness.py new file mode 100644 index 00000000..29a3b5f3 --- /dev/null +++ b/tests/integration/test_postgres_harness.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import os + +import psycopg +import pytest +from sqlalchemy import Engine, text + +from engine.persistence import ( + DatabaseConfiguration, + assert_runtime_role, + create_database_engine, +) +from engine.persistence.configuration import ( + MIGRATOR_ROLE, + RUNTIME_ROLE, + WORKER_ROLE, +) + +pytestmark = pytest.mark.integration + + +def role_attributes(engine: Engine) -> tuple[object, ...]: + with engine.connect() as connection: + return tuple( + connection.execute( + text( + """ + SELECT + current_user, + role.rolsuper, + role.rolcreaterole, + role.rolcreatedb, + role.rolcanlogin, + role.rolreplication, + role.rolbypassrls, + role.rolinherit + FROM pg_roles AS role + WHERE role.rolname = current_user + """ + ) + ).one() + ) + + +def test_server_is_postgresql_17_with_pgvector_0_8_5( + guarded_runtime_engine: Engine, +) -> None: + with guarded_runtime_engine.connect() as connection: + version_number = connection.execute( + text("SELECT current_setting('server_version_num')::integer") + ).scalar_one() + extension_version = connection.execute( + text("SELECT extversion FROM pg_extension WHERE extname = 'vector'") + ).scalar_one() + + assert version_number // 10_000 == 17 + assert extension_version == "0.8.5" + + +def test_migration_runtime_and_worker_roles_have_reviewed_capabilities( + migration_configuration: DatabaseConfiguration, + runtime_configuration: DatabaseConfiguration, + worker_configuration: DatabaseConfiguration, +) -> None: + configurations = ( + migration_configuration, + runtime_configuration, + worker_configuration, + ) + results: dict[str, tuple[object, ...]] = {} + for configuration in configurations: + engine = create_database_engine(configuration) + try: + results[configuration.expected_role] = role_attributes(engine) + finally: + engine.dispose() + + assert set(results) == {MIGRATOR_ROLE, RUNTIME_ROLE, WORKER_ROLE} + for role_name, attributes in results.items(): + assert attributes == ( + role_name, + False, + False, + False, + True, + False, + False, + False, + ) + + +def test_runtime_and_worker_are_not_owners_or_migrator_members( + guarded_runtime_engine: Engine, + worker_configuration: DatabaseConfiguration, +) -> None: + worker_engine = create_database_engine(worker_configuration) + try: + engines = (guarded_runtime_engine, worker_engine) + for engine in engines: + with engine.connect() as connection: + facts = tuple( + connection.execute( + text( + """ + SELECT + pg_get_userbyid(database.datdba) = current_user, + pg_get_userbyid(namespace.nspowner) = current_user, + pg_has_role(current_user, :migrator, 'MEMBER'), + pg_has_role(current_user, :migrator, 'USAGE'), + has_database_privilege( + current_user, current_database(), 'CREATE' + ), + has_schema_privilege( + current_user, 'public', 'CREATE' + ) + FROM pg_database AS database + JOIN pg_namespace AS namespace + ON namespace.nspname = 'public' + WHERE database.datname = current_database() + """ + ), + {"migrator": MIGRATOR_ROLE}, + ).one() + ) + assert facts == (False, False, False, False, False, False) + finally: + worker_engine.dispose() + + +def test_runtime_and_worker_have_no_create_or_temporary_table_privilege( + guarded_runtime_engine: Engine, + worker_configuration: DatabaseConfiguration, +) -> None: + worker_engine = create_database_engine(worker_configuration) + try: + for engine in (guarded_runtime_engine, worker_engine): + with engine.connect() as connection: + privileges = tuple( + connection.execute( + text( + """ + SELECT + has_database_privilege( + current_user, current_database(), 'CREATE' + ), + has_database_privilege( + current_user, current_database(), 'TEMPORARY' + ), + has_schema_privilege( + current_user, 'public', 'CREATE' + ) + """ + ) + ).one() + ) + assert privileges == (False, False, False) + finally: + worker_engine.dispose() + + +def test_migrator_owns_database_schema_and_alembic_metadata( + migration_configuration: DatabaseConfiguration, +) -> None: + engine = create_database_engine(migration_configuration) + try: + with engine.connect() as connection: + owners = tuple( + connection.execute( + text( + """ + SELECT + pg_get_userbyid(database.datdba), + pg_get_userbyid(namespace.nspowner), + pg_get_userbyid(relation.relowner) + FROM pg_database AS database + JOIN pg_namespace AS namespace + ON namespace.nspname = 'public' + JOIN pg_class AS relation + ON relation.relnamespace = namespace.oid + AND relation.relname = 'alembic_version' + WHERE database.datname = current_database() + """ + ) + ).one() + ) + assert owners == (MIGRATOR_ROLE, MIGRATOR_ROLE, MIGRATOR_ROLE) + finally: + engine.dispose() + + +def test_role_guard_passes_runtime_and_rejects_owner_credentials( + guarded_runtime_engine: Engine, + migration_configuration: DatabaseConfiguration, +) -> None: + with guarded_runtime_engine.connect() as connection: + assert_runtime_role(connection) + + migration_engine = create_database_engine(migration_configuration) + try: + with ( + migration_engine.connect() as connection, + pytest.raises(AssertionError, match="non-owner runtime role"), + ): + assert_runtime_role(connection) + finally: + migration_engine.dispose() + + +@pytest.mark.parametrize( + ("membership_options", "inherits_probe_privilege"), + [ + ("WITH SET TRUE, INHERIT FALSE, ADMIN FALSE", False), + ("WITH SET FALSE, INHERIT TRUE, ADMIN FALSE", True), + ("WITH SET FALSE, INHERIT FALSE, ADMIN TRUE", False), + ], +) +def test_role_guard_rejects_every_unrelated_role_membership( + guarded_runtime_engine: Engine, + membership_options: str, + inherits_probe_privilege: bool, +) -> None: + with psycopg.connect( + host="127.0.0.1", + port=int(os.environ["CONTEXT_ENGINE_POSTGRES_PORT"]), + dbname=os.environ["POSTGRES_DB"], + user=os.environ["POSTGRES_USER"], + password=os.environ["POSTGRES_PASSWORD"], + ) as bootstrap_connection: + bootstrap_connection.execute("DROP ROLE IF EXISTS context_engine_guard_probe") + bootstrap_connection.execute( + "CREATE ROLE context_engine_guard_probe NOLOGIN NOSUPERUSER" + ) + bootstrap_connection.execute( + "GRANT SELECT ON public.alembic_version TO context_engine_guard_probe" + ) + bootstrap_connection.execute( + f"GRANT context_engine_guard_probe TO {RUNTIME_ROLE} " + f"{membership_options}" + ) + bootstrap_connection.commit() + try: + with guarded_runtime_engine.connect() as connection: + assert connection.execute( + text( + "SELECT has_table_privilege(" + "current_user, 'public.alembic_version', 'SELECT')" + ) + ).scalar_one() is inherits_probe_privilege + with ( + guarded_runtime_engine.connect() as connection, + pytest.raises(AssertionError, match="role_memberships"), + ): + assert_runtime_role(connection) + finally: + bootstrap_connection.execute( + f"REVOKE context_engine_guard_probe FROM {RUNTIME_ROLE}" + ) + bootstrap_connection.execute( + "REVOKE SELECT ON public.alembic_version " + "FROM context_engine_guard_probe" + ) + bootstrap_connection.execute("DROP ROLE context_engine_guard_probe") + bootstrap_connection.commit() diff --git a/tests/unit/test_database_configuration.py b/tests/unit/test_database_configuration.py new file mode 100644 index 00000000..fecad71c --- /dev/null +++ b/tests/unit/test_database_configuration.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from collections.abc import Mapping + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.engine import make_url +from sqlalchemy.pool import QueuePool + +from engine.persistence.configuration import ( + MIGRATOR_ROLE, + RUNTIME_ROLE, + WORKER_ROLE, + DatabaseConfigurationError, + DatabasePurpose, + load_database_configuration, + load_harness_database_configurations, +) +from engine.persistence.database import create_database_engine + + +def database_environment() -> dict[str, str]: + return { + "CONTEXT_ENGINE_MIGRATION_DATABASE_URL": ( + "postgresql+psycopg://context_engine_migrator:migration-secret@" + "127.0.0.1:5432/context_engine" + ), + "CONTEXT_ENGINE_RUNTIME_DATABASE_URL": ( + "postgresql+psycopg://context_engine_runtime:runtime-secret@" + "127.0.0.1:5432/context_engine" + ), + "CONTEXT_ENGINE_WORKER_DATABASE_URL": ( + "postgresql+psycopg://context_engine_worker:worker-secret@" + "127.0.0.1:5432/context_engine" + ), + "CONTEXT_ENGINE_TEST_DATABASE_URL": ( + "postgresql+psycopg://context_engine_runtime:runtime-secret@" + "127.0.0.1:5432/context_engine" + ), + "CONTEXT_ENGINE_MIGRATOR_ROLE": MIGRATOR_ROLE, + "CONTEXT_ENGINE_RUNTIME_ROLE": RUNTIME_ROLE, + "CONTEXT_ENGINE_WORKER_ROLE": WORKER_ROLE, + } + + +@pytest.mark.parametrize( + ("purpose", "missing_name"), + [ + (DatabasePurpose.MIGRATION, "CONTEXT_ENGINE_MIGRATION_DATABASE_URL"), + (DatabasePurpose.API_RUNTIME, "CONTEXT_ENGINE_RUNTIME_DATABASE_URL"), + (DatabasePurpose.SUPPLY_WORKER, "CONTEXT_ENGINE_WORKER_DATABASE_URL"), + (DatabasePurpose.SECURITY_TEST, "CONTEXT_ENGINE_TEST_DATABASE_URL"), + ], +) +def test_each_process_requires_its_own_database_url( + purpose: DatabasePurpose, missing_name: str +) -> None: + environment = database_environment() + environment.pop(missing_name) + + with pytest.raises(DatabaseConfigurationError, match=missing_name): + load_database_configuration(purpose, environment) + + +def test_runtime_never_falls_back_to_migration_credentials() -> None: + environment = database_environment() + environment.pop("CONTEXT_ENGINE_RUNTIME_DATABASE_URL") + + with pytest.raises( + DatabaseConfigurationError, + match="CONTEXT_ENGINE_RUNTIME_DATABASE_URL", + ): + load_database_configuration(DatabasePurpose.API_RUNTIME, environment) + + +@pytest.mark.parametrize( + ("purpose", "url_name", "unsafe_role"), + [ + ( + DatabasePurpose.API_RUNTIME, + "CONTEXT_ENGINE_RUNTIME_DATABASE_URL", + MIGRATOR_ROLE, + ), + ( + DatabasePurpose.SUPPLY_WORKER, + "CONTEXT_ENGINE_WORKER_DATABASE_URL", + MIGRATOR_ROLE, + ), + ( + DatabasePurpose.SECURITY_TEST, + "CONTEXT_ENGINE_TEST_DATABASE_URL", + MIGRATOR_ROLE, + ), + ], +) +def test_non_migration_configuration_rejects_a_privileged_url_username( + purpose: DatabasePurpose, url_name: str, unsafe_role: str +) -> None: + environment = database_environment() + environment[url_name] = ( + f"postgresql+psycopg://{unsafe_role}:secret@127.0.0.1:5432/context_engine" + ) + + with pytest.raises(DatabaseConfigurationError, match="expected database role"): + load_database_configuration(purpose, environment) + + +def test_role_name_contract_cannot_be_redefined_by_environment() -> None: + environment = database_environment() + environment["CONTEXT_ENGINE_RUNTIME_ROLE"] = MIGRATOR_ROLE + + with pytest.raises( + DatabaseConfigurationError, + match="CONTEXT_ENGINE_RUNTIME_ROLE must be 'context_engine_runtime'", + ): + load_database_configuration(DatabasePurpose.API_RUNTIME, environment) + + +@pytest.mark.parametrize( + "environment", + [ + { + **database_environment(), + "CONTEXT_ENGINE_RUNTIME_DATABASE_URL": ( + "sqlite+pysqlite:///:memory:" + ), + }, + { + **database_environment(), + "CONTEXT_ENGINE_RUNTIME_DATABASE_URL": ( + "postgresql+psycopg://context_engine_runtime@" + "127.0.0.1:5432/context_engine" + ), + }, + ], +) +def test_database_url_must_be_explicit_postgresql_psycopg_credentials( + environment: Mapping[str, str], +) -> None: + with pytest.raises(DatabaseConfigurationError): + load_database_configuration(DatabasePurpose.API_RUNTIME, environment) + + +def test_database_url_rejects_query_parameters_that_override_login_identity() -> None: + environment = database_environment() + environment["CONTEXT_ENGINE_RUNTIME_DATABASE_URL"] += ( + "?user=context_engine_migrator&password=migration-secret" + ) + + with pytest.raises(DatabaseConfigurationError, match="query parameters"): + load_database_configuration(DatabasePurpose.API_RUNTIME, environment) + + +def test_harness_contract_keeps_roles_distinct_and_test_uses_runtime() -> None: + configurations = load_harness_database_configurations(database_environment()) + + assert configurations.migration.expected_role == MIGRATOR_ROLE + assert configurations.runtime.expected_role == RUNTIME_ROLE + assert configurations.worker.expected_role == WORKER_ROLE + assert configurations.security_test.expected_role == RUNTIME_ROLE + assert configurations.security_test.url == configurations.runtime.url + + +def test_configuration_representation_never_contains_a_password() -> None: + configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + assert "runtime-secret" not in repr(configuration) + assert RUNTIME_ROLE in repr(configuration) + + +def test_configuration_cannot_be_forged_with_a_cross_purpose_role() -> None: + runtime_configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + with pytest.raises(DatabaseConfigurationError, match="match its purpose"): + type(runtime_configuration)( + purpose=DatabasePurpose.MIGRATION, + url=runtime_configuration.url, + expected_role=RUNTIME_ROLE, + ) + + +def test_configuration_cannot_be_forged_with_a_cross_role_url() -> None: + runtime_configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + with pytest.raises(DatabaseConfigurationError, match="URL username"): + type(runtime_configuration)( + purpose=DatabasePurpose.MIGRATION, + url=runtime_configuration.url, + expected_role=MIGRATOR_ROLE, + ) + + +def test_configuration_cannot_be_forged_with_a_non_postgresql_url() -> None: + runtime_configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + with pytest.raises(DatabaseConfigurationError, match=r"postgresql\+psycopg"): + type(runtime_configuration)( + purpose=DatabasePurpose.API_RUNTIME, + url=make_url("sqlite+pysqlite:///:memory:"), + expected_role=RUNTIME_ROLE, + ) + + +def test_configuration_cannot_be_forged_with_query_parameter_overrides() -> None: + runtime_configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + with pytest.raises(DatabaseConfigurationError, match="query parameters"): + type(runtime_configuration)( + purpose=DatabasePurpose.API_RUNTIME, + url=make_url( + "postgresql+psycopg://context_engine_runtime:runtime-secret@" + "127.0.0.1:5432/context_engine?host=privileged.example" + ), + expected_role=RUNTIME_ROLE, + ) + + +def test_database_engine_installs_non_optional_reset_and_checkout_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + listeners: list[str] = [] + real_engine = create_engine("sqlite+pysqlite:///:memory:") + + def fake_create_engine(url: object, **options: object) -> object: + assert options == { + "pool_pre_ping": True, + "pool_reset_on_return": None, + "pool_size": 5, + "max_overflow": 10, + } + return real_engine + + def fake_listen(pool: QueuePool, event_name: str, callback: object) -> None: + assert pool is real_engine.pool + assert callable(callback) + listeners.append(event_name) + + monkeypatch.setattr("engine.persistence.database.create_engine", fake_create_engine) + monkeypatch.setattr(event, "listen", fake_listen) + configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + assert create_database_engine(configuration) is real_engine + assert listeners == ["reset", "checkout"] + + +def test_database_engine_caller_cannot_disable_the_reset_policy() -> None: + configuration = load_database_configuration( + DatabasePurpose.API_RUNTIME, database_environment() + ) + + with pytest.raises(TypeError, match="unexpected keyword argument"): + create_database_engine( # type: ignore[call-arg] + configuration, pool_reset_on_return="rollback" + ) diff --git a/tests/unit/test_database_harness_behavior.py b/tests/unit/test_database_harness_behavior.py new file mode 100644 index 00000000..5fa03597 --- /dev/null +++ b/tests/unit/test_database_harness_behavior.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).parents[2] + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents, encoding="utf-8") + path.chmod(0o755) + + +def _run_stubbed_harness(checkout: Path, stub_directory: Path) -> tuple[str, str]: + scripts = checkout / "scripts" + scripts.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "scripts/database_harness.sh", scripts) + (checkout / "compose.yaml").write_text("services: {}\n", encoding="utf-8") + command_log = checkout / "docker-command.log" + environment = { + **os.environ, + "HARNESS_COMMAND_LOG": str(command_log), + "PATH": f"{stub_directory}{os.pathsep}{os.environ['PATH']}", + } + + subprocess.run( + ["/bin/bash", str(scripts / "database_harness.sh"), "up"], + check=True, + capture_output=True, + text=True, + env=environment, + ) + + environment_contents = (checkout / ".context-engine/database.env").read_text( + encoding="utf-8" + ) + project = next( + line.partition("=")[2] + for line in environment_contents.splitlines() + if line.startswith("CONTEXT_ENGINE_COMPOSE_PROJECT=") + ) + return project, command_log.read_text(encoding="utf-8") + + +def _stub_harness_dependencies(stub_directory: Path) -> None: + _write_executable( + stub_directory / "docker", + "#!/usr/bin/env bash\n" + "printf '%s\\n' \"$*\" >>\"$HARNESS_COMMAND_LOG\"\n", + ) + _write_executable(stub_directory / "uv", "#!/usr/bin/env bash\nexit 0\n") + + +def test_two_checkouts_generate_distinct_persistent_compose_projects( + tmp_path: Path, +) -> None: + stub_directory = tmp_path / "bin" + stub_directory.mkdir() + _stub_harness_dependencies(stub_directory) + first_checkout = tmp_path / "first" + second_checkout = tmp_path / "second" + + first_project, first_command = _run_stubbed_harness( + first_checkout, stub_directory + ) + second_project, second_command = _run_stubbed_harness( + second_checkout, stub_directory + ) + + assert first_project.startswith("context-engine-") + assert second_project.startswith("context-engine-") + assert first_project != second_project + assert f"--project-name {first_project}" in first_command + assert f"--project-name {second_project}" in second_command + + repeated_project, repeated_command = _run_stubbed_harness( + first_checkout, stub_directory + ) + assert repeated_project == first_project + assert f"--project-name {first_project}" in repeated_command + + +def test_concurrent_first_use_converges_on_one_persisted_compose_project( + tmp_path: Path, +) -> None: + stub_directory = tmp_path / "bin" + stub_directory.mkdir() + _stub_harness_dependencies(stub_directory) + barrier_directory = tmp_path / "barrier" + barrier_directory.mkdir() + _write_executable( + stub_directory / "ln", + "#!/usr/bin/env bash\n" + "if [[ \"$2\" == */database.env ]]; then\n" + " marker=\"$HARNESS_BARRIER_DIR/$$\"\n" + " : >\"$marker\"\n" + " if mkdir \"$HARNESS_BARRIER_DIR/leader\" 2>/dev/null; then\n" + " while [[ $(find \"$HARNESS_BARRIER_DIR\" -type f | wc -l) -lt 2 ]]; do\n" + " sleep 0.01\n" + " done\n" + " else\n" + " while [[ $(find \"$HARNESS_BARRIER_DIR\" -type f | wc -l) -lt 2 ]]; do\n" + " sleep 0.01\n" + " done\n" + " sleep 0.3\n" + " fi\n" + "fi\n" + "exec /bin/ln \"$@\"\n", + ) + checkout = tmp_path / "checkout" + scripts = checkout / "scripts" + scripts.mkdir(parents=True) + shutil.copy2(ROOT / "scripts/database_harness.sh", scripts) + (checkout / "compose.yaml").write_text("services: {}\n", encoding="utf-8") + command_log = checkout / "docker-command.log" + environment = { + **os.environ, + "HARNESS_BARRIER_DIR": str(barrier_directory), + "HARNESS_COMMAND_LOG": str(command_log), + "PATH": f"{stub_directory}{os.pathsep}{os.environ['PATH']}", + } + + processes = [ + subprocess.Popen( + ["/bin/bash", str(scripts / "database_harness.sh"), "up"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=environment, + ) + for _ in range(2) + ] + outputs = [process.communicate(timeout=15) for process in processes] + + assert [process.returncode for process in processes] == [0, 0], outputs + projects = re.findall( + r"--project-name (context-engine-[0-9a-f]{16})", + command_log.read_text(encoding="utf-8"), + ) + environment_contents = (checkout / ".context-engine/database.env").read_text( + encoding="utf-8" + ) + persisted_project = next( + line.partition("=")[2] + for line in environment_contents.splitlines() + if line.startswith("CONTEXT_ENGINE_COMPOSE_PROJECT=") + ) + assert len(projects) == 2 + assert set(projects) == {persisted_project} + + +def test_legacy_two_file_state_migrates_without_changing_project_identity( + tmp_path: Path, +) -> None: + stub_directory = tmp_path / "bin" + stub_directory.mkdir() + _stub_harness_dependencies(stub_directory) + checkout = tmp_path / "checkout" + project, _ = _run_stubbed_harness(checkout, stub_directory) + environment_path = checkout / ".context-engine/database.env" + legacy_environment = "\n".join( + line + for line in environment_path.read_text(encoding="utf-8").splitlines() + if not line.startswith("CONTEXT_ENGINE_COMPOSE_PROJECT=") + ) + environment_path.write_text(f"{legacy_environment}\n", encoding="utf-8") + environment_path.chmod(0o600) + project_path = checkout / ".context-engine/compose-project" + project_path.write_text(f"{project}\n", encoding="utf-8") + project_path.chmod(0o600) + + migrated_project, command = _run_stubbed_harness(checkout, stub_directory) + + assert migrated_project == project + assert f"--project-name {project}" in command + assert project_path.read_text(encoding="utf-8").strip() == project diff --git a/tests/unit/test_database_harness_contract.py b/tests/unit/test_database_harness_contract.py new file mode 100644 index 00000000..24af212f --- /dev/null +++ b/tests/unit/test_database_harness_contract.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +ROOT = Path(__file__).parents[2] + + +def repository_text(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def test_compose_pins_postgresql_17_pgvector_0_8_5_by_digest() -> None: + compose = repository_text("compose.yaml") + + assert ( + "pgvector/pgvector:0.8.5-pg17-bookworm@" + "sha256:d2ef61f42ef767baa5a1475393303cc235bcd92febd9d7014eddb48b41f3bad0" + ) in compose + assert "127.0.0.1:${CONTEXT_ENGINE_POSTGRES_PORT" in compose + assert "./infra/postgres/init:/docker-entrypoint-initdb.d:ro" in compose + + +def test_compose_project_identity_is_generated_per_checkout() -> None: + compose = repository_text("compose.yaml") + script = repository_text("scripts/database_harness.sh") + + assert "name: context-engine-dev" not in compose + assert 'readonly COMPOSE_PROJECT="context-engine-dev"' not in script + assert "CONTEXT_ENGINE_COMPOSE_PROJECT" in script + assert '--project-name "$COMPOSE_PROJECT"' in script + + +@pytest.mark.parametrize( + "sql_variable", + [ + "migrator_role", + "runtime_role", + "worker_role", + ], +) +def test_role_bootstrap_keeps_each_login_nonsuperuser_nobypass_noinherit( + sql_variable: str, +) -> None: + bootstrap = repository_text("infra/postgres/init/10-security-roles.sh") + declaration = bootstrap.split(f'CREATE ROLE :"{sql_variable}"', maxsplit=1)[1] + declaration = declaration.split(";", maxsplit=1)[0] + + assert "LOGIN" in declaration + assert "NOSUPERUSER" in declaration + assert "NOBYPASSRLS" in declaration + assert "NOINHERIT" in declaration + + +def test_database_harness_generates_secret_state_and_never_sources_it() -> None: + script = repository_text("scripts/database_harness.sh") + + assert "umask 077" in script + assert 'chmod 600 "$temporary_file"' in script + assert 'source "$ENV_FILE"' not in script + assert 'ln "$temporary_file" "$ENV_FILE"' in script + assert 'mv "$temporary_file" "$ENV_FILE"' not in script + assert "unexpected variable" in script + assert "role-isolated URL contract" in script + + +@pytest.mark.parametrize( + ("catalog_attribute", "guard_alias"), + [ + ("rolcreaterole", "can_create_roles"), + ("rolcreatedb", "can_create_databases"), + ("rolreplication", "can_replicate"), + ], +) +def test_runtime_role_guard_checks_every_role_escalation_attribute( + catalog_attribute: str, guard_alias: str +) -> None: + guard = repository_text("engine/persistence/role_guard.py") + + assert f"role.{catalog_attribute} AS {guard_alias}" in guard + assert f'"{guard_alias}": False' in guard + + +def test_runtime_role_guard_rejects_every_membership() -> None: + guard = repository_text("engine/persistence/role_guard.py") + + assert "FROM pg_auth_members AS membership" in guard + assert "membership.member = role.oid" in guard + assert "AS has_no_role_memberships" in guard + assert '"has_no_role_memberships": True' in guard + + +def test_ci_runs_the_same_make_database_contract_as_local() -> None: + workflow = repository_text(".github/workflows/ci.yml") + makefile = repository_text("Makefile") + + assert "make db-up" in workflow + assert "make check" in workflow + assert "if: always()" in workflow + assert "make db-down" in workflow + assert "check: build lint typecheck test catalog smoke integration" in makefile + assert "./scripts/database_harness.sh integration" in makefile diff --git a/uv.lock b/uv.lock index 88b3caa0..c462c4bd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 3 requires-python = "==3.13.*" +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -58,7 +72,10 @@ name = "context-engine" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "fastapi" }, + { name = "psycopg", extra = ["binary"] }, + { name = "sqlalchemy" }, { name = "uvicorn" }, ] @@ -72,7 +89,10 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.16,<1.17" }, { name = "fastapi", specifier = ">=0.116,<0.117" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<3.3" }, + { name = "sqlalchemy", specifier = ">=2.0,<2.1" }, { name = "uvicorn", specifier = ">=0.35,<0.36" }, ] @@ -98,6 +118,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/e4/c543271a8018874b7f682bf6156863c416e1334b8ed3e51a69495c5d4360/fastapi-0.116.2-py3-none-any.whl", hash = "sha256:c3a7a8fb830b05f7e087d920e0d786ca1fc9892eb4e9a84b227be4c1bc7569db", size = 95670, upload-time = "2025-09-16T18:29:21.329Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -153,6 +191,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, +] + [[package]] name = "mypy" version = "1.17.1" @@ -209,6 +289,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/05/d4a05988f15fcf90e0088c735b1f2fc04a30b7fc65461d6ec278f5f2f17a/psycopg-3.2.13.tar.gz", hash = "sha256:309adaeda61d44556046ec9a83a93f42bbe5310120b1995f3af49ab6d9f13c1d", size = 160626, upload-time = "2025-11-21T22:34:32.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/14/f2724bd1986158a348316e86fdd0837a838b14a711df3f00e47fba597447/psycopg-3.2.13-py3-none-any.whl", hash = "sha256:a481374514f2da627157f767a9336705ebefe93ea7a0522a6cbacba165da179a", size = 206797, upload-time = "2025-11-21T22:29:39.733Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.2.13" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/ec/ef37bb44dc02fcc6c0a3eeb93f4baaac13bcb228633fe38ad3fb5a3f6449/psycopg_binary-3.2.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbae6ab1966e2b61d97e47220556c330c4608bb4cfb3a124aa0595c39995c068", size = 3995628, upload-time = "2025-11-21T22:31:45.921Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/4748f5f1a40248af16dba087dbec50bd335ee025cc1fb9bf64773378ceff/psycopg_binary-3.2.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fae933e4564386199fc54845d85413eedb49760e0bcd2b621fde2dd1825b99b3", size = 4069024, upload-time = "2025-11-21T22:31:50.202Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/f02ec6bbc30c7fcd3b39823d2d624b42fae480edeb6e50eb3276281d5635/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:13e2f8894d410678529ff9f1211f96c5a93ff142f992b302682b42d924428b61", size = 4615127, upload-time = "2025-11-21T22:31:56.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0d/a54fc2cdd672c84175d6869cc823d6ec2a8909318d491f3c24e6077983f2/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f26f7009375cf1e92180e5c517c52da1054f7e690dde90e0ed00fa8b5736bcd4", size = 4710267, upload-time = "2025-11-21T22:32:04.585Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b7/067de1acaf3d312253351f3af4121f972584bd36cada6378d4b0cdcebd38/psycopg_binary-3.2.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea2fdbcc9142933a47c66970e0df8b363e3bd1ea4c5ce376f2f3d94a9aeec847", size = 4400795, upload-time = "2025-11-21T22:32:08.883Z" }, + { url = "https://files.pythonhosted.org/packages/64/b5/030e6b1ebfc4d3a8fca03adc5fc827982643bad0b01a1268538d17c08ed3/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac92d6bc1d4a41c7459953a9aa727b9966e937e94c9e072527317fd2a67d488b", size = 3851239, upload-time = "2025-11-21T22:32:12.333Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/0541845364a7de9eae6807060da6a04b22a8eb2e803606d285d9250fbe93/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8b843c00478739e95c46d6d3472b13123b634685f107831a9bfc41503a06ecbd", size = 3525084, upload-time = "2025-11-21T22:32:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/83/ae/6507890dc30a4bbd9d938d4ff3a4079d009a5ad8170af51c7f762438fdbf/psycopg_binary-3.2.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2f63868cc96bc18486cebec24445affbdd7f7debf28fac466ea935a8b5a4753b", size = 3576787, upload-time = "2025-11-21T22:32:19.922Z" }, + { url = "https://files.pythonhosted.org/packages/9d/64/3d1c2f1fd09b60cdfbe68b9a810b357ba505eff6e4bdb1a2d9f6729da64c/psycopg_binary-3.2.13-cp313-cp313-win_amd64.whl", hash = "sha256:594dfbca3326e997ae738d3d339004e8416b1f7390f52ce8dc2d692393e8fa96", size = 2905584, upload-time = "2025-11-21T22:32:23.399Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -301,6 +414,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + [[package]] name = "starlette" version = "0.48.0" @@ -334,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0"