diff --git a/src/aignostics_foundry_core/AGENTS.md b/src/aignostics_foundry_core/AGENTS.md index bf44aec..59b3a1d 100644 --- a/src/aignostics_foundry_core/AGENTS.md +++ b/src/aignostics_foundry_core/AGENTS.md @@ -371,7 +371,7 @@ This file provides an overview of all modules in `aignostics_foundry_core`, thei - **Purpose**: Manages a process-level async database engine singleton, providing session injection for FastAPI routes, background jobs, and CLI commands. All public functions accept optional DB-config params and fall back to the active `FoundryContext.database` when they are `None`. - **Key Features**: - - `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. When `db_url` is `None`, the URL and pool settings are resolved from `get_context().database`; raises `RuntimeError` if no context is installed or `ctx.database` is `None`. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`). + - `init_engine(db_url=None, pool_size=None, pool_max_overflow=None, pool_timeout=None)` — initialises the global `AsyncEngine` and `async_sessionmaker`; subsequent calls are silent no-ops. When `db_url` is `None`, the URL and pool settings are resolved from `get_context().database`; raises `RuntimeError` if no context is installed or `ctx.database` is `None`. Pool parameters are omitted automatically for SQLite (which does not use `QueuePool`). The engine sets `hide_parameters=True`, so SQLAlchemy error messages do not contain bound parameter values. - `dispose_engine()` — async; disposes the engine; called during application shutdown. - `get_db_session()` — async generator; yields an `AsyncSession`; raises `RuntimeError` if engine not initialised. Use as a FastAPI `Depends` target. - `execute_with_session(async_func, *args, **kwargs)` — async; runs `async_func` with a session injected as the `session` keyword argument. For background jobs and CLI helpers. diff --git a/src/aignostics_foundry_core/database.py b/src/aignostics_foundry_core/database.py index 0435fe1..cfa9079 100644 --- a/src/aignostics_foundry_core/database.py +++ b/src/aignostics_foundry_core/database.py @@ -211,6 +211,9 @@ def init_engine( Called during FastAPI lifespan startup or first job execution. Subsequent calls are no-ops (engine is already initialized). + The engine hides bound parameter values in SQLAlchemy error messages, because + these messages go to logs and Sentry. + For multiprocessing: Engine is automatically reset in child processes via multiprocessing.util.register_after_fork(). @@ -255,6 +258,8 @@ def init_engine( "url": db_url, "pool_pre_ping": True, "echo": False, + # Keep bound parameter values out of error messages. They go to logs and Sentry. + "hide_parameters": True, } if not db_url.startswith("sqlite"): engine_kwargs["pool_size"] = pool_size diff --git a/tests/aignostics_foundry_core/database_test.py b/tests/aignostics_foundry_core/database_test.py index c86e25e..1612424 100644 --- a/tests/aignostics_foundry_core/database_test.py +++ b/tests/aignostics_foundry_core/database_test.py @@ -5,6 +5,8 @@ from pathlib import Path import pytest +from sqlalchemy import text +from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession from aignostics_foundry_core.database import ( @@ -24,6 +26,7 @@ DB_URL_ERROR_FRAGMENT = "DB_URL" SESSION_KWARG = "session" +SENSITIVE_PARAMETER_VALUE = "sensitive-parameter-value-vpthp-128" @pytest.fixture(autouse=True) @@ -74,6 +77,20 @@ async def test_init_engine_is_idempotent(self, sqlite_url: str) -> None: init_engine(sqlite_url) # Must not raise; second call is a silent no-op await execute_with_session(noop) # Session maker still functional + @pytest.mark.unit + async def test_failed_statement_error_hides_parameter_values(self, sqlite_url: str) -> None: + """A failed statement gives an error message without the bound parameter values.""" + + async def select_from_missing_table(session: AsyncSession) -> None: + await session.execute(text("SELECT * FROM missing_table WHERE x = :v"), {"v": SENSITIVE_PARAMETER_VALUE}) + + init_engine(sqlite_url) + with pytest.raises(OperationalError) as exc_info: + await execute_with_session(select_from_missing_table) + + assert SENSITIVE_PARAMETER_VALUE not in str(exc_info.value) + assert "hide_parameters=True" in str(exc_info.value) + @pytest.mark.unit async def test_init_engine_non_sqlite_url_accepted(self) -> None: """init_engine accepts a non-SQLite URL without raising (engine creation is lazy)."""