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:
Chris Coutinho
2026-05-16 18:53:45 +02:00
co-authored by Claude Opus 4.7
parent 292cbb3292
commit f2b7bf132f
14 changed files with 510 additions and 22 deletions
+48 -7
View File
@@ -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()