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]>
This commit is contained in:
2026-08-05 20:44:35 -04:00
co-authored by Claude Sonnet 5
parent a9ac6454c6
commit 3e1fa1845b
5 changed files with 49 additions and 18 deletions
+16 -9
View File
@@ -33,13 +33,12 @@ router = APIRouter(prefix="/auth", tags=["auth"])
async def _enforce_turnstile(
request: Request, settings: Settings, db: AsyncSession, token: str | None, client_ip: str
request: Request, settings: Settings, effective: Settings, token: str | None, client_ip: str
) -> None:
"""Required for register/login/request-password-reset when the caller
isn't on loopback and a secret is configured - skipped entirely
otherwise (see turnstile_service.turnstile_required). The secret may be
admin-configured (system_secret_service) rather than only .env-set."""
effective = await system_secret_service.get_effective_settings(db, settings)
if not turnstile_required(is_localhost(request, settings), effective):
return
if not token or not await verify_turnstile(token, client_ip, effective):
@@ -55,8 +54,13 @@ async def register(
db: AsyncSession = Depends(get_db),
) -> UserResponse:
client_ip = get_client_ip(request, settings)
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
user = await auth_service.register(db, settings, client_ip, payload)
# Effective (DB-override-aware) settings, computed once and reused for
# both Turnstile enforcement and the verification email - the Resend
# API key, like the Turnstile secret, may be admin-configured via
# system_secret_service rather than only .env-set.
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
user = await auth_service.register(db, effective, client_ip, payload)
return UserResponse.model_validate(user)
@@ -69,8 +73,9 @@ async def login(
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
client_ip = get_client_ip(request, settings)
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
return await auth_service.login(db, settings, client_ip, payload)
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
return await auth_service.login(db, effective, client_ip, payload)
@router.post("/refresh", response_model=TokenResponse)
@@ -112,7 +117,8 @@ async def resend_verification(
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
await auth_service.resend_verification(db, settings, client_ip, payload)
effective = await system_secret_service.get_effective_settings(db, settings)
await auth_service.resend_verification(db, effective, client_ip, payload)
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
@@ -123,8 +129,9 @@ async def request_password_reset(
db: AsyncSession = Depends(get_db),
) -> None:
client_ip = get_client_ip(request, settings)
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
await auth_service.request_password_reset(db, settings, client_ip, payload)
effective = await system_secret_service.get_effective_settings(db, settings)
await _enforce_turnstile(request, settings, effective, payload.turnstile_token, client_ip)
await auth_service.request_password_reset(db, effective, client_ip, payload)
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -174,3 +174,4 @@ class SystemSecretKey(StrEnum):
TURNSTILE_SITE_KEY = "turnstile_site_key"
TURNSTILE_SECRET = "turnstile_secret"
RESEND_API_KEY = "resend_api_key"
+10 -6
View File
@@ -1,10 +1,11 @@
"""Server-wide secrets an admin can configure from the Settings page
instead of only via .env - today just the Cloudflare Turnstile site key
and secret (app/services/turnstile_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.
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
@@ -35,6 +36,9 @@ META: dict[SystemSecretKey, SecretMeta] = {
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"
),
}