gabriel / musehub public
migrate_webhook_secrets.py python
108 lines 3.4 KB
e6fad116 Remove all Stori, Maestro, and AgentCeption references; rebrand to Muse VCS Gabriel Cardona <gabriel@tellurstori.com> 6d ago
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 ``musehub.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 muse 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())