arcly_http/auth/secrets.rs
1//! Hot-rotating secrets without restarts and without locks.
2//!
3//! ## The problem
4//!
5//! Secrets read from env at boot are frozen for the process lifetime: rotating
6//! `JWT_SECRET` means restarting the whole fleet and invalidating every live
7//! token at once. Plaintext also lingers in the container environment
8//! (`/proc/<pid>/environ`), which fails most compliance reviews.
9//!
10//! ## The model
11//!
12//! - [`SecretSource`] — *where* secrets come from (Vault, AWS Secrets Manager,
13//! env for dev). The app implements it; the framework never links a cloud SDK
14//! — the same rule that keeps `OAuth2Provider` implementations app-side.
15//! - [`Rotating<T>`] — *how* live key material is held: an `ArcSwap`, so the
16//! request hot path pays one atomic pointer load (no `Mutex`/`RwLock`),
17//! while a background watcher swaps in new material atomically.
18//! - [`spawn_secret_watcher`] — polls the source on an interval from
19//! `ArclyPlugin::on_start` and invokes a callback when the version changes.
20//!
21//! Services that own derived key material (`JwtService`, `CookieService`)
22//! hold a `Rotating<…bundle…>` internally and keep the **previous** key for
23//! verification during a grace window, so rotation never mass-invalidates
24//! tokens that are still inside their TTL.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use arc_swap::ArcSwap;
30use futures::future::BoxFuture;
31use secrecy::SecretString;
32
33// ─── Source abstraction ───────────────────────────────────────────────────────
34
35/// One fetched secret value plus a monotonically increasing version.
36///
37/// The version lets watchers detect change without comparing plaintext, and
38/// guards against accidental rollback (a lower version is never applied).
39pub struct SecretVersion {
40 pub value: SecretString,
41 pub version: u64,
42}
43
44/// External secret backend — Vault, AWS Secrets Manager, env (dev), …
45///
46/// Object-safe via `BoxFuture`, same pattern as `SessionStore`.
47pub trait SecretSource: Send + Sync + 'static {
48 fn fetch<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result<SecretVersion, String>>;
49}
50
51// ─── Lock-free rotating holder ────────────────────────────────────────────────
52
53/// Atomically swappable key material.
54///
55/// Hot path: [`load`](Self::load) is a single atomic pointer load (~1 ns) —
56/// no locks, no allocation. Control path: [`store`](Self::store) replaces the
57/// whole bundle at once, so readers always observe a consistent key set.
58pub struct Rotating<T> {
59 inner: ArcSwap<T>,
60}
61
62impl<T> Rotating<T> {
63 pub fn new(initial: T) -> Self {
64 Self {
65 inner: ArcSwap::from_pointee(initial),
66 }
67 }
68
69 #[inline]
70 pub fn load(&self) -> arc_swap::Guard<Arc<T>> {
71 self.inner.load()
72 }
73
74 /// Swap in a new bundle. Call from rotation/control paths only.
75 pub fn store(&self, next: T) {
76 self.inner.store(Arc::new(next));
77 }
78}
79
80// ─── Background watcher ───────────────────────────────────────────────────────
81
82/// Poll `source` for `key` every `interval`; when the version increases,
83/// invoke `on_change` with the new secret.
84///
85/// Spawn from `ArclyPlugin::on_start` — the existing lifecycle hook — so no
86/// new mechanism is needed for graceful shutdown (the task dies with the
87/// runtime). Fetch errors are logged and retried on the next tick; the
88/// previous key material stays live, so a flaky source can never lock the
89/// service out of its own keys.
90pub fn spawn_secret_watcher(
91 source: Arc<dyn SecretSource>,
92 key: &'static str,
93 interval: Duration,
94 on_change: impl Fn(SecretVersion) + Send + Sync + 'static,
95) {
96 tokio::spawn(async move {
97 let mut last_version: u64 = 0;
98 let mut ticker = tokio::time::interval(interval);
99 // First tick fires immediately; skip it — boot already loaded v1.
100 ticker.tick().await;
101 loop {
102 ticker.tick().await;
103 match source.fetch(key).await {
104 Ok(sv) if sv.version > last_version => {
105 tracing::info!(key, version = sv.version, "secret rotated — applying");
106 last_version = sv.version;
107 on_change(sv);
108 }
109 Ok(_) => { /* unchanged */ }
110 Err(e) => {
111 tracing::warn!(key, error = %e, "secret fetch failed — keeping current key");
112 }
113 }
114 }
115 });
116}