rustlavel-vault 0.7.2

Secrets from OpenBao or HashiCorp Vault: KV, dynamic database credentials, leases
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Keeping leases alive in the background.
//!
//! A dynamic credential is only useful because it expires, and it only works
//! because somebody renews it. That somebody is a task: it wakes at the
//! two-thirds point of each lease (see [`Lease::renew_after`]), renews, and
//! goes back to sleep. The third of the lease it deliberately leaves is what a
//! failed renewal is retried inside.
//!
//! What it renews *with* is a callback. The renewer never calls the store
//! itself, so extending a token, a database credential, or something an engine
//! written later issues are all the same job here, and none of them needs a
//! branch in this file.
//!
//! ```ignore
//! let renewer = Renewer::new();
//! renewer.watch("database", lease, move |lease| {
//!     let client = client.clone();
//!     async move { renew_lease(&client, &lease).await }
//! });
//! ```

use crate::lease::Lease;
use rustlavel_core::Result;
use std::future::Future;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::watch;
use tokio::task::JoinHandle;

/// The shortest a renewer will ever sleep.
///
/// This is the guard against a busy loop, and it is needed: a lease whose
/// two-thirds point has already passed answers `renew_after()` with zero every
/// time it is asked, so without a floor the task would renew as fast as the
/// store could answer — which is a denial of service against your own Vault,
/// written by accident.
pub const MIN_INTERVAL: Duration = Duration::from_secs(1);

/// Renews leases until it is told to stop.
pub struct Renewer {
    stop: watch::Sender<bool>,
    min_interval: Duration,
    tasks: Mutex<Vec<JoinHandle<()>>>,
    watching: std::sync::Arc<AtomicUsize>,
}

impl Default for Renewer {
    fn default() -> Renewer {
        Renewer::new()
    }
}

impl Renewer {
    pub fn new() -> Renewer {
        Renewer {
            // A `watch` rather than a `Notify` because a lease handed over
            // after the shutdown signal must still see it: a watch receiver
            // reads the current value, a notification that already fired is
            // gone.
            stop: watch::channel(false).0,
            min_interval: MIN_INTERVAL,
            tasks: Mutex::new(Vec::new()),
            watching: std::sync::Arc::new(AtomicUsize::new(0)),
        }
    }

    /// The floor on how often a lease may be renewed.
    ///
    /// Tests turn it down to keep themselves quick. Production should not need
    /// to touch it — a lease short enough for this to matter is a lease that
    /// should have been issued longer.
    pub fn min_interval(mut self, interval: Duration) -> Renewer {
        self.min_interval = interval;
        self
    }

    /// How many leases are still being watched.
    pub fn watching(&self) -> usize {
        self.watching.load(Ordering::SeqCst)
    }

    /// Keep a lease alive, calling `renew` each time it comes due.
    ///
    /// `renew` is handed the current lease and returns the renewed one — which
    /// is [`Lease::renewed`] for an extension, or a whole new `Lease` for
    /// something that was rotated rather than extended.
    ///
    /// A lease that cannot be renewed is not watched at all. Asking Vault to
    /// renew something it will not renew is a request that can only fail, and
    /// doing it on a timer is a request that fails forever.
    pub fn watch<F, Fut>(&self, name: impl Into<String>, lease: Lease, renew: F)
    where
        F: Fn(Lease) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Result<Lease>> + Send + 'static,
    {
        let name = name.into();

        if !lease.renewable || !lease.exists() {
            rustlavel_core::debug!("`{name}` carries no renewable lease; nothing to renew");
            return;
        }

        // Without a runtime there is nothing to spawn onto. That happens when
        // an application builds itself outside `#[tokio::main]`, and saying so
        // is far better than the panic `tokio::spawn` would raise from inside
        // a plugin's `register`.
        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
            rustlavel_core::warn!(
                "not renewing the lease on `{name}`: no tokio runtime is running, so there is \
                 nothing to renew it from. The credential will stop working in {}s.",
                lease.remaining().as_secs()
            );
            return;
        };

        let stop = self.stop.subscribe();
        let min_interval = self.min_interval;
        let watching = self.watching.clone();
        watching.fetch_add(1, Ordering::SeqCst);

        let handle = runtime.spawn(async move {
            renew_until_stopped(name, lease, renew, min_interval, stop).await;
            watching.fetch_sub(1, Ordering::SeqCst);
        });

        self.tasks.lock().unwrap_or_else(|e| e.into_inner()).push(handle);
    }

    /// Tell every task to stop, without waiting.
    ///
    /// `send_replace` rather than `send`: a `watch` sender with no receivers
    /// refuses a `send` and leaves the value alone, which would mean stopping
    /// a renewer before it had watched anything did nothing at all — and the
    /// lease handed over a moment later would run on.
    pub fn stop(&self) {
        self.stop.send_replace(true);
    }

    /// Stop, and wait for every task to finish.
    ///
    /// A task is only ever between sleeps or inside one renewal, so this
    /// returns in about as long as one call to the store takes.
    pub async fn shutdown(&self) {
        self.stop();

        let tasks: Vec<JoinHandle<()>> =
            self.tasks.lock().unwrap_or_else(|e| e.into_inner()).drain(..).collect();
        for task in tasks {
            let _ = task.await;
        }
    }
}

/// Dropping the renewer stops the tasks it started.
///
/// Without this a plugin that went out of scope would leave its tasks renewing
/// leases for an application that no longer exists.
impl Drop for Renewer {
    fn drop(&mut self) {
        self.stop.send_replace(true);
    }
}

/// No leases, no tokens, nothing to redact — but written out rather than
/// derived, so it stays that way if a field is ever added.
impl std::fmt::Debug for Renewer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Renewer")
            .field("watching", &self.watching())
            .field("min_interval", &self.min_interval)
            .finish()
    }
}

async fn renew_until_stopped<F, Fut>(
    name: String,
    mut lease: Lease,
    renew: F,
    min_interval: Duration,
    mut stop: watch::Receiver<bool>,
) where
    F: Fn(Lease) -> Fut + Send + Sync + 'static,
    Fut: Future<Output = Result<Lease>> + Send + 'static,
{
    let mut wait = lease.renew_after().max(min_interval);

    loop {
        if sleep_or_stop(wait, &mut stop).await {
            rustlavel_core::debug!("stopped renewing `{name}`");
            return;
        }

        match renew(lease.clone()).await {
            Ok(renewed) => {
                rustlavel_core::debug!(
                    "renewed the lease on `{name}` for {}s",
                    renewed.duration.as_secs()
                );
                lease = renewed;

                if !lease.renewable || !lease.exists() {
                    rustlavel_core::warn!(
                        "`{name}` came back from a renewal without a renewable lease. It stops \
                         working in {}s and renewing cannot prevent that — whatever issued it \
                         has to issue a new one.",
                        lease.remaining().as_secs()
                    );
                    return;
                }

                wait = lease.renew_after().max(min_interval);
            }
            Err(error) => {
                if lease.is_expired() {
                    rustlavel_core::error!(
                        "lost the lease on `{name}`: {error}. Whatever it held open — a database \
                         account, a token — has expired, and this process is now using a \
                         credential the store no longer honours."
                    );
                    return;
                }

                rustlavel_core::warn!(
                    "could not renew `{name}`: {error}. Retrying; {}s left on the lease.",
                    lease.remaining().as_secs()
                );

                // Inside the third the two-thirds rule left over, and getting
                // shorter as that third runs out — but never below the floor,
                // so a store that is refusing quickly cannot be hammered.
                wait = (lease.remaining() / 4).max(min_interval);
            }
        }
    }
}

/// Sleep, unless shutdown comes first. `true` means stop.
async fn sleep_or_stop(wait: Duration, stop: &mut watch::Receiver<bool>) -> bool {
    // Checked before selecting: a receiver that subscribed after the signal has
    // already marked it seen, and `changed()` would then wait for a second one
    // that never comes.
    if *stop.borrow() {
        return true;
    }

    tokio::select! {
        _ = tokio::time::sleep(wait) => *stop.borrow(),
        // An error means the sender is gone, which is a stop as much as a
        // signal is.
        _ = stop.changed() => true,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustlavel_core::Error;
    use std::sync::Arc;
    use std::time::Instant;

    /// A lease that is `elapsed` old, so a test can start one part-way through.
    fn aged(duration: Duration, elapsed: Duration) -> Lease {
        Lease {
            id: "database/creds/app/abc".into(),
            duration,
            renewable: true,
            issued: Instant::now() - elapsed,
        }
    }

    /// Wait for a condition, or give up. Never asserts on how long something
    /// took, only that it eventually happened — a loaded machine may be slow,
    /// but it cannot make the renewer fire early.
    async fn within(limit: Duration, mut done: impl FnMut() -> bool) -> bool {
        let deadline = Instant::now() + limit;
        while Instant::now() < deadline {
            if done() {
                return true;
            }
            tokio::time::sleep(Duration::from_millis(5)).await;
        }
        done()
    }

    #[tokio::test]
    async fn renews_at_the_two_thirds_point_and_not_before() {
        let renewals = Arc::new(AtomicUsize::new(0));
        let counter = renewals.clone();

        let renewer = Renewer::new().min_interval(Duration::from_millis(10));
        renewer.watch("database", aged(Duration::from_millis(300), Duration::ZERO), move |lease| {
            let counter = counter.clone();
            async move {
                counter.fetch_add(1, Ordering::SeqCst);
                Ok(lease.renewed(Duration::from_millis(300), true))
            }
        });

        // The two-thirds point is 200ms away, so nothing may have happened yet.
        tokio::time::sleep(Duration::from_millis(80)).await;
        assert_eq!(renewals.load(Ordering::SeqCst), 0, "renewed far too early");

        assert!(
            within(Duration::from_secs(3), || renewals.load(Ordering::SeqCst) >= 1).await,
            "the lease was never renewed"
        );

        renewer.shutdown().await;
    }

    #[tokio::test]
    async fn retries_a_renewal_that_failed_within_the_time_that_is_left() {
        let attempts = Arc::new(AtomicUsize::new(0));
        let counter = attempts.clone();

        let renewer = Renewer::new().min_interval(Duration::from_millis(10));
        // Two thirds of the way through already, so the first attempt is due
        // at once and there is a third of the lease left to retry inside.
        renewer.watch(
            "database",
            aged(Duration::from_secs(30), Duration::from_secs(20)),
            move |lease| {
                let counter = counter.clone();
                async move {
                    // The first attempt fails, the second succeeds.
                    if counter.fetch_add(1, Ordering::SeqCst) == 0 {
                        Err(Error::msg("the secret store is on a standby node"))
                    } else {
                        Ok(lease.renewed(Duration::from_secs(30), true))
                    }
                }
            },
        );

        assert!(
            within(Duration::from_secs(3), || attempts.load(Ordering::SeqCst) >= 2).await,
            "a failed renewal was never retried"
        );

        renewer.shutdown().await;
    }

    #[tokio::test]
    async fn gives_up_when_the_lease_is_lost_rather_than_retrying_forever() {
        let attempts = Arc::new(AtomicUsize::new(0));
        let counter = attempts.clone();

        let renewer = Renewer::new().min_interval(Duration::from_millis(10));
        renewer.watch("database", aged(Duration::from_millis(60), Duration::ZERO), move |_| {
            let counter = counter.clone();
            async move {
                counter.fetch_add(1, Ordering::SeqCst);
                Err(Error::msg("permission denied"))
            }
        });

        // The task ends on its own once the lease expires: there is nothing
        // left to renew, and a task that kept trying would log forever.
        assert!(
            within(Duration::from_secs(3), || renewer.watching() == 0).await,
            "the renewer never gave up on an expired lease"
        );
        assert!(attempts.load(Ordering::SeqCst) >= 1, "it gave up without trying");
    }

    #[tokio::test]
    async fn stops_cleanly_on_shutdown() {
        let renewals = Arc::new(AtomicUsize::new(0));
        let counter = renewals.clone();

        let renewer = Renewer::new();
        // An hour-long lease: nothing is due for forty minutes, so shutting
        // down has to interrupt the sleep rather than wait it out.
        renewer.watch("token", aged(Duration::from_secs(3600), Duration::ZERO), move |lease| {
            let counter = counter.clone();
            async move {
                counter.fetch_add(1, Ordering::SeqCst);
                Ok(lease.renewed(Duration::from_secs(3600), true))
            }
        });
        assert_eq!(renewer.watching(), 1);

        let stopped = tokio::time::timeout(Duration::from_secs(5), renewer.shutdown()).await;

        assert!(stopped.is_ok(), "shutdown waited for the sleep instead of interrupting it");
        assert_eq!(renewer.watching(), 0);
        assert_eq!(renewals.load(Ordering::SeqCst), 0, "nothing was due");
    }

    #[tokio::test]
    async fn a_lease_handed_over_after_shutdown_stops_immediately() {
        let renewer = Renewer::new().min_interval(Duration::from_millis(10));
        renewer.stop();

        renewer.watch("late", aged(Duration::from_secs(30), Duration::from_secs(29)), |lease| async move {
            Ok(lease.renewed(Duration::from_secs(30), true))
        });

        assert!(
            within(Duration::from_secs(3), || renewer.watching() == 0).await,
            "a task started after shutdown kept running"
        );
    }

    #[tokio::test]
    async fn a_lease_that_cannot_be_renewed_is_not_watched_at_all() {
        let renewer = Renewer::new();
        let mut lease = aged(Duration::from_secs(60), Duration::ZERO);
        lease.renewable = false;

        renewer.watch("static", lease, |lease| async move { Ok(lease) });
        renewer.watch("none", Lease::none(), |lease| async move { Ok(lease) });

        assert_eq!(renewer.watching(), 0);
    }

    #[tokio::test]
    async fn an_overdue_lease_does_not_become_a_busy_loop() {
        let renewals = Arc::new(AtomicUsize::new(0));
        let counter = renewals.clone();

        // Renewed into a lease that is already past its own two-thirds point,
        // which is what would spin without the floor.
        let renewer = Renewer::new().min_interval(Duration::from_millis(50));
        renewer.watch("database", aged(Duration::from_secs(30), Duration::from_secs(29)), move |_| {
            let counter = counter.clone();
            async move {
                counter.fetch_add(1, Ordering::SeqCst);
                Ok(Lease {
                    id: "database/creds/app/abc".into(),
                    duration: Duration::from_secs(30),
                    renewable: true,
                    issued: Instant::now() - Duration::from_secs(29),
                })
            }
        });

        tokio::time::sleep(Duration::from_millis(250)).await;
        renewer.shutdown().await;

        let count = renewals.load(Ordering::SeqCst);
        assert!(count >= 1, "it should still have renewed");
        assert!(count <= 10, "one renewal per 50ms at most, got {count} in 250ms");
    }
}