migrate_webhook_secrets.py
python
| 1 | """One-time migration script: encrypt plaintext webhook secrets. |
| 2 | |
| 3 | Context |
| 4 | ------- |
| 5 | PR #336 added Fernet envelope encryption to ``musehub_webhooks.secret`` via |
| 6 | ``maestro.services.musehub_webhook_crypto``. Existing rows written before |
| 7 | STORI_WEBHOOK_SECRET_KEY was set contain plaintext secrets. When the key is |
| 8 | first enabled in production, ``decrypt_secret()`` would normally raise a |
| 9 | ValueError for those rows. PR #347 added a transparent fallback, but the |
| 10 | recommended path is to run this script once to encrypt every legacy row before |
| 11 | or immediately after enabling the key. |
| 12 | |
| 13 | Behaviour |
| 14 | --------- |
| 15 | - Reads every row in ``musehub_webhooks`` where ``secret != ''``. |
| 16 | - Detects whether each value is already a Fernet token (starts with "gAAAAAB"). |
| 17 | - If not, encrypts the plaintext and writes the token back. |
| 18 | - Idempotent: safe to run multiple times; already-encrypted rows are skipped. |
| 19 | - Exits with a summary count and a non-zero exit code only on unexpected errors. |
| 20 | |
| 21 | Usage |
| 22 | ----- |
| 23 | Run inside the container (bind mount makes this file available): |
| 24 | |
| 25 | docker compose exec maestro python3 /app/scripts/migrate_webhook_secrets.py |
| 26 | |
| 27 | Requires STORI_WEBHOOK_SECRET_KEY to be set; exits with an error if absent. |
| 28 | """ |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | import asyncio |
| 32 | import logging |
| 33 | import sys |
| 34 | |
| 35 | from sqlalchemy import select, update |
| 36 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine |
| 37 | |
| 38 | from musehub.config import settings |
| 39 | from musehub.db.musehub_models import MusehubWebhook |
| 40 | from musehub.services.musehub_webhook_crypto import encrypt_secret, is_fernet_token |
| 41 | |
| 42 | logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") |
| 43 | logger = logging.getLogger(__name__) |
| 44 | |
| 45 | |
| 46 | async def migrate(db: AsyncSession) -> tuple[int, int]: |
| 47 | """Encrypt all plaintext secrets. |
| 48 | |
| 49 | Returns ``(migrated, skipped)`` counts where *migrated* is the number of |
| 50 | rows updated and *skipped* is the number already encrypted. |
| 51 | """ |
| 52 | result = await db.execute( |
| 53 | select(MusehubWebhook).where(MusehubWebhook.secret != "") |
| 54 | ) |
| 55 | webhooks = result.scalars().all() |
| 56 | |
| 57 | migrated = 0 |
| 58 | skipped = 0 |
| 59 | |
| 60 | for webhook in webhooks: |
| 61 | if is_fernet_token(webhook.secret): |
| 62 | skipped += 1 |
| 63 | continue |
| 64 | |
| 65 | encrypted = encrypt_secret(webhook.secret) |
| 66 | await db.execute( |
| 67 | update(MusehubWebhook) |
| 68 | .where(MusehubWebhook.webhook_id == webhook.webhook_id) |
| 69 | .values(secret=encrypted) |
| 70 | ) |
| 71 | logger.info("✅ Migrated webhook %s", webhook.webhook_id) |
| 72 | migrated += 1 |
| 73 | |
| 74 | await db.commit() |
| 75 | return migrated, skipped |
| 76 | |
| 77 | |
| 78 | async def main() -> None: |
| 79 | if not settings.webhook_secret_key: |
| 80 | logger.error( |
| 81 | "❌ STORI_WEBHOOK_SECRET_KEY is not set. " |
| 82 | "Set this environment variable before running the migration." |
| 83 | ) |
| 84 | sys.exit(1) |
| 85 | |
| 86 | db_url: str = settings.database_url or "" |
| 87 | engine = create_async_engine(db_url, echo=False) |
| 88 | async_session: async_sessionmaker[AsyncSession] = async_sessionmaker( |
| 89 | engine, class_=AsyncSession, expire_on_commit=False |
| 90 | ) |
| 91 | |
| 92 | async with async_session() as db: |
| 93 | migrated, skipped = await migrate(db) |
| 94 | |
| 95 | await engine.dispose() |
| 96 | |
| 97 | logger.info( |
| 98 | "✅ Migration complete — %d secret(s) encrypted, %d already encrypted (skipped).", |
| 99 | migrated, |
| 100 | skipped, |
| 101 | ) |
| 102 | |
| 103 | if migrated == 0 and skipped == 0: |
| 104 | logger.info("ℹ️ No webhooks with non-empty secrets found.") |
| 105 | |
| 106 | |
| 107 | if __name__ == "__main__": |
| 108 | asyncio.run(main()) |