fix(storage): address PR #798 review feedback (credentials, asyncpg extra, TLS, pool)
Round-2 fixes after the bot review on PR #798 plus two user follow-ups (self-signed Postgres support; asyncpg should be a PyPI extra). Folded into the same PR rather than a follow-up since the work is still unmerged. Security -------- - Mask database credentials in all 5 log call sites (storage.py × 4, migrations.py × 1) via a new `mask_db_password()` helper in config.py. Uses SQLAlchemy's `make_url(...).render_as_string(hide_password=True)` with a regex fallback so the masking path never raises. - New `tests/unit/test_storage_logging.py` asserts a sentinel password never appears in `caplog` during `RefreshTokenStorage.initialize()`. Distribution ------------ - `asyncpg` moved to `[project.optional-dependencies] postgres` so a vanilla `pip install nextcloud-mcp-server` no longer pulls in the ~5 MB C extension. The Docker image runs `uv sync --extra postgres`, so containerized deployments are unchanged. - When `DATABASE_URL=postgresql+asyncpg://...` is set on a venv missing the extra, `RefreshTokenStorage.initialize()` raises a friendly RuntimeError pointing at `[postgres]` rather than the generic ModuleNotFoundError. TLS for the Postgres backend ---------------------------- - New `DATABASE_VERIFY_SSL` + `DATABASE_CA_BUNDLE` env vars mirror the existing `NEXTCLOUD_VERIFY_SSL` / `NEXTCLOUD_CA_BUNDLE` pattern (validators in Settings.__post_init__, `get_database_ssl()` helper alongside `get_nextcloud_ssl_verify()`). `DATABASE_VERIFY_SSL=false` wins over `DATABASE_CA_BUNDLE` for incident-response convenience. - Default is **None** rather than True — keeps PR #798's behavior intact for cluster-internal Postgres that runs without TLS. Operators opt into verify-full or supply a private CA. ADR-026 records the reasoning vs the Nextcloud HTTPS default. - Engine factory in `storage.py` passes `ssl` via `connect_args` only when `get_database_ssl()` returns non-None; otherwise asyncpg's default (`prefer`) applies. - Storage logs which TLS mode is active at INFO (no secret material). Configurable connection pool ---------------------------- - `DATABASE_POOL_SIZE` (default 10) and `DATABASE_MAX_OVERFLOW` (default 20) replace the hardcoded engine values. With many replicas this can blow past managed-Postgres `max_connections=100`; tune down for large fleets. - gte-1 / gte-0 validators in __post_init__ reject 0/negative pool sizes at startup with the offending value in the error. Consistency polish ------------------ - Migration 006: convert raw `op.execute("ALTER TABLE ... ADD COLUMN")` to `op.batch_alter_table(...).add_column(sa.Column("nonce", sa.Text))` for stylistic consistency with the rewritten 001-005. Downgrade now drops the column instead of being a no-op. - `registered_webhooks.created_at` standardized from `sa.Float` to `sa.BigInteger` (all other `*_at` columns); `store_webhook()` casts `time.time()` → `int`. - `is_sqlite_url()` made case-insensitive. Testing ------- - New `tests/integration/test_storage_postgres.py::test_cleanup_expired_roundtrip` exercises `cleanup_expired_tokens`, `cleanup_expired_sessions`, and `cleanup_expired_browser_sessions` — relies on DELETE rowcount, historically dialect-tricky. - `tests/unit/test_ssl_config.py` extended with `TestDatabaseSSLSettings` + `TestGetDatabaseSSL` classes (9 new tests) mirroring the existing Nextcloud SSL tests one-for-one. Docs ---- - `docs/configuration.md` Centralized-Storage section grew the four new env vars + a homelab example with a private CA. - `docs/ADR-026` grew Distribution, TLS, and `alembic/env.py` async-pattern subsections explaining the non-obvious design choices. Helm chart counterpart in cbcoutinho/helm-charts PR #34 (separate commit on `feat/nextcloud-mcp-server-database-url`). Verification ------------ - `uv run pytest tests/unit/` — 1025 passed. - `TEST_DATABASE_URL=... uv run pytest tests/integration/test_storage_postgres.py -m postgres` — 6 passed (including new cleanup test). - `uv run ruff check && uv run ruff format --check && uv run ty check -- nextcloud_mcp_server` — clean. Tracked on Astrolabe Cloud POC board, card #99. --- _This PR was generated with the help of AI, and reviewed by a Human_ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
292cbb3292
commit
f2b7bf132f
@@ -25,6 +25,7 @@ Token storage requires TOKEN_ENCRYPTION_KEY, but webhook tracking does not.
|
||||
Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric encryption.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -44,9 +45,12 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_en
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from nextcloud_mcp_server.config import (
|
||||
get_database_ssl,
|
||||
get_database_url,
|
||||
get_settings,
|
||||
is_ephemeral_token_db,
|
||||
is_sqlite_url,
|
||||
mask_db_password,
|
||||
)
|
||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||
@@ -345,7 +349,9 @@ class RefreshTokenStorage:
|
||||
sqlite_path,
|
||||
)
|
||||
else:
|
||||
logger.info("Using centralized token storage at %s", database_url)
|
||||
logger.info(
|
||||
"Using centralized token storage at %s", mask_db_password(database_url)
|
||||
)
|
||||
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
|
||||
encryption_key = None
|
||||
@@ -423,11 +429,43 @@ class RefreshTokenStorage:
|
||||
future=True,
|
||||
)
|
||||
else:
|
||||
# Postgres ships as an optional PyPI extra (`[postgres]`) so the
|
||||
# default `pip install nextcloud-mcp-server` audience doesn't
|
||||
# pull in asyncpg's C extension. The Docker image bundles it.
|
||||
# Surface a clear actionable error when the driver is missing
|
||||
# rather than the generic ModuleNotFoundError SQLAlchemy emits.
|
||||
if "+asyncpg" in self.database_url.lower() and (
|
||||
importlib.util.find_spec("asyncpg") is None
|
||||
):
|
||||
raise RuntimeError(
|
||||
"DATABASE_URL points at Postgres via asyncpg but the "
|
||||
"'asyncpg' driver is not installed. Install with "
|
||||
"`pip install nextcloud-mcp-server[postgres]` or use "
|
||||
"the Docker image, which bundles it. See ADR-026."
|
||||
)
|
||||
# Conditionally pass TLS config through to asyncpg. When
|
||||
# get_database_ssl() returns None we omit ``ssl`` entirely so
|
||||
# asyncpg's default (``prefer``) applies — keeps cluster-local
|
||||
# Postgres without TLS working out of the box. See ADR-026.
|
||||
connect_args: dict[str, object] = {}
|
||||
ssl_arg = get_database_ssl()
|
||||
if ssl_arg is not None:
|
||||
connect_args["ssl"] = ssl_arg
|
||||
logger.info(
|
||||
"Postgres backend TLS: %s",
|
||||
"disabled"
|
||||
if ssl_arg is False
|
||||
else "custom CA bundle"
|
||||
if not isinstance(ssl_arg, bool)
|
||||
else "verify-full (system CAs)",
|
||||
)
|
||||
settings = get_settings()
|
||||
self.engine = create_async_engine(
|
||||
self.database_url,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_size=settings.database_pool_size,
|
||||
max_overflow=settings.database_max_overflow,
|
||||
pool_pre_ping=True,
|
||||
connect_args=connect_args,
|
||||
future=True,
|
||||
)
|
||||
self._dialect = self.engine.dialect.name
|
||||
@@ -446,7 +484,7 @@ class RefreshTokenStorage:
|
||||
if has_schema:
|
||||
logger.info(
|
||||
"Detected pre-Alembic database at %s, stamping with initial revision",
|
||||
self.database_url,
|
||||
mask_db_password(self.database_url),
|
||||
)
|
||||
await to_thread.run_sync(stamp_database, self.database_url, "001")
|
||||
logger.info(
|
||||
@@ -456,7 +494,7 @@ class RefreshTokenStorage:
|
||||
else:
|
||||
logger.info(
|
||||
"Initializing new database at %s with migrations",
|
||||
self.database_url,
|
||||
mask_db_password(self.database_url),
|
||||
)
|
||||
await to_thread.run_sync(upgrade_database, self.database_url, "head")
|
||||
logger.info("Database initialized with migrations")
|
||||
@@ -468,7 +506,10 @@ class RefreshTokenStorage:
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Initialized refresh token storage at %s", self.database_url)
|
||||
logger.info(
|
||||
"Initialized refresh token storage at %s",
|
||||
mask_db_password(self.database_url),
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _db(self):
|
||||
@@ -1638,7 +1679,7 @@ class RefreshTokenStorage:
|
||||
preset_id = EXCLUDED.preset_id,
|
||||
created_at = EXCLUDED.created_at
|
||||
""",
|
||||
(webhook_id, preset_id, time.time()),
|
||||
(webhook_id, preset_id, int(time.time())),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user