Refactor storage to provider-agnostic band-scoped model
Replaces per-member Nextcloud credentials with a BandStorage model that supports multiple providers. Credentials are Fernet-encrypted at rest; worker receives audio via an internal streaming endpoint instead of direct storage access. - Add BandStorage DB model with partial unique index (one active per band) - Add migrations 0007 (create band_storage) and 0008 (drop old nc columns) - Add StorageFactory that builds the correct StorageClient from BandStorage - Add storage router: connect/nextcloud, OAuth2 authorize/callback, list, disconnect - Add Fernet encryption helpers in security/encryption.py - Rewrite watcher for per-band polling via internal API config endpoint - Update worker to stream audio from API instead of accessing storage directly - Update frontend: new storage API in bands.ts, rewritten StorageSection, simplified band creation modal (no storage step) - Add STORAGE_ENCRYPTION_KEY to all docker-compose files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
38
api/src/rehearsalhub/security/encryption.py
Normal file
38
api/src/rehearsalhub/security/encryption.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Fernet-based symmetric encryption for storage credentials.
|
||||
|
||||
The encryption key must be a 32-byte URL-safe base64-encoded string,
|
||||
generated once via: Fernet.generate_key().decode()
|
||||
and stored in the STORAGE_ENCRYPTION_KEY environment variable.
|
||||
|
||||
No credentials are ever stored in plaintext — only the encrypted blob
|
||||
is written to the database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
|
||||
def encrypt_credentials(key: str, data: dict) -> str:
|
||||
"""Serialize *data* to JSON and encrypt it with Fernet.
|
||||
|
||||
Returns a URL-safe base64-encoded ciphertext string safe to store in TEXT columns.
|
||||
"""
|
||||
f = Fernet(key.encode())
|
||||
plaintext = json.dumps(data, separators=(",", ":")).encode()
|
||||
return f.encrypt(plaintext).decode()
|
||||
|
||||
|
||||
def decrypt_credentials(key: str, blob: str) -> dict:
|
||||
"""Decrypt and deserialize a blob previously created by :func:`encrypt_credentials`.
|
||||
|
||||
Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the blob is tampered.
|
||||
"""
|
||||
f = Fernet(key.encode())
|
||||
try:
|
||||
plaintext = f.decrypt(blob.encode())
|
||||
except InvalidToken:
|
||||
raise InvalidToken("Failed to decrypt storage credentials — wrong key or corrupted blob")
|
||||
return json.loads(plaintext)
|
||||
Reference in New Issue
Block a user