Files
CIAgent/apps/api/app/services/system_secret_service.py
T
sakshamandClaude Sonnet 5 3e1fa1845b Add Resend API key to admin-configurable Server secrets
Was only ever settable via .env - now follows the same pattern as
Cloudflare Turnstile: SystemSecretKey.RESEND_API_KEY + a META entry
covers storage/encryption/frontend rendering automatically (the
Settings UI's Server secrets box is fully data-driven off this list).
Wired the register/login/resend-verification/request-password-reset
route handlers to use get_effective_settings so an admin-set key
actually reaches the emails those flows send, not just .env's value.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 20:44:35 -04:00

102 lines
3.5 KiB
Python

"""Server-wide secrets an admin can configure from the Settings page
instead of only via .env - the Cloudflare Turnstile site key and secret
(app/services/turnstile_service.py) and the Resend API key
(app/services/security_email_service.py). Storage is encrypted at rest
(app/core/crypto.py). Unlike per-user API keys (user_api_key_service.py),
there's exactly one value per key, shared by the whole app -
visible/editable only to admins (see app/api/v1/system.py's require_admin
gate), never per-user.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import Settings
from app.core.crypto import decrypt_secret, encrypt_secret
from app.models.enums import SecurityEventType, SystemSecretKey
from app.repositories.system_secret_repository import SystemSecretRepository
from app.repositories.user_security_event_repository import UserSecurityEventRepository
@dataclass(frozen=True)
class SecretMeta:
label: str
settings_field: str
META: dict[SystemSecretKey, SecretMeta] = {
SystemSecretKey.TURNSTILE_SITE_KEY: SecretMeta(
label="Cloudflare Turnstile Site Key", settings_field="turnstile_site_key"
),
SystemSecretKey.TURNSTILE_SECRET: SecretMeta(
label="Cloudflare Turnstile Secret Key", settings_field="turnstile_secret"
),
SystemSecretKey.RESEND_API_KEY: SecretMeta(
label="Resend API Key", settings_field="resend_api_key"
),
}
async def list_status(db: AsyncSession, settings: Settings) -> list[dict[str, Any]]:
repo = SystemSecretRepository(db)
stored = {row.key: row for row in await repo.list_all()}
results: list[dict[str, Any]] = []
for key, meta in META.items():
row = stored.get(key)
value = decrypt_secret(row.encrypted_value, settings) if row is not None else None
results.append(
{
"key": key.value,
"label": meta.label,
"configured": row is not None,
"value": value,
}
)
return results
async def set_secret(
db: AsyncSession,
key: SystemSecretKey,
plaintext_value: str,
settings: Settings,
*,
admin_user_id: uuid.UUID,
client_ip: str,
) -> None:
"""A blank value clears the stored override, falling back to the
server's .env-configured value again. Every update - including a clear -
is logged to the acting admin's own Account activity, since this is a
security-sensitive, app-wide change (Turnstile keys today)."""
repo = SystemSecretRepository(db)
stripped = plaintext_value.strip()
if not stripped:
await repo.delete(key)
else:
await repo.upsert(key, encrypt_secret(stripped, settings))
await UserSecurityEventRepository(db).create(
user_id=admin_user_id,
event_type=SecurityEventType.SERVER_SECRET_UPDATED,
ip_address=client_ip,
)
await db.commit()
async def get_effective_settings(db: AsyncSession, settings: Settings) -> Settings:
"""A copy of the global settings with any admin-stored secret
substituted in for the matching field - keys with no stored override
keep using the server's .env-configured default."""
rows = await SystemSecretRepository(db).list_all()
if not rows:
return settings
overrides = {
META[row.key].settings_field: decrypt_secret(row.encrypted_value, settings) for row in rows
}
return settings.model_copy(update=overrides)