Skip to main content

rustlavel_vault/
leases.rs

1//! Keeping a grant alive, and ending it early.
2//!
3//! A lease is only a security property if somebody acts on it. Renewing is the
4//! boring half: a process holding database credentials has to ask for more time
5//! before two thirds of the lease has gone, or the account is dropped underneath
6//! it. Revoking is the half that matters when something goes wrong — it is the
7//! difference between "the leaked credential expires in an hour" and "the leaked
8//! credential stopped working the moment we noticed".
9//!
10//! Vault splits these across two vocabularies for no reason a caller cares
11//! about: everything with a lease id goes through `sys/leases/…`, and a token
12//! goes through `auth/token/…-self` because a token's lease has no id.
13
14use crate::client::VaultClient;
15use crate::error::VaultError;
16use rustlavel_core::Result;
17use crate::lease::Lease;
18use rustlavel_core::Json;
19use std::time::Duration;
20
21impl VaultClient {
22    /// Ask for more time on a lease.
23    ///
24    /// The increment is a request, not an instruction: Vault caps it at the
25    /// role's max TTL and answers with what it actually gave, which is why the
26    /// renewed lease has to come from the response rather than from the
27    /// argument. Renewing a lease that has already lapsed fails — there is
28    /// nothing left to extend, and the account is already gone.
29    pub async fn renew_lease(&self, lease: &Lease, increment: Option<Duration>) -> Result<Lease> {
30        if lease.id().is_empty() {
31            return Err(VaultError::BadRequest(
32                "this lease has no id, so it is a token's. Renew it with `renew_token`.".into(),
33            )
34            .into());
35        }
36
37        let mut body = vec![("lease_id".to_string(), Json::from(lease.id()))];
38        if let Some(increment) = increment {
39            body.push(("increment".into(), Json::Number(increment.as_secs() as f64)));
40        }
41
42        let response = self.post("sys/leases/renew", Json::object(body)).await?;
43        Ok(lease.renewed(response.lease.duration(), response.lease.renewable()))
44    }
45
46    /// End a lease now, deleting whatever it was holding open.
47    ///
48    /// Idempotent: Vault answers 204 for a lease it has never heard of, so a
49    /// shutdown path that revokes twice does not need to care.
50    pub async fn revoke_lease(&self, lease: &Lease) -> Result<()> {
51        if lease.id().is_empty() {
52            return Err(VaultError::BadRequest(
53                "this lease has no id, so it is a token's. Revoke it with `revoke_token`.".into(),
54            )
55            .into());
56        }
57
58        self.post("sys/leases/revoke", Json::object([("lease_id", Json::from(lease.id()))]))
59            .await
60            .map(|_| ())
61    }
62
63    /// What Vault currently thinks of a lease.
64    ///
65    /// The honest way to find out whether a credential is still alive, as
66    /// opposed to asking the local [`Lease`], which only knows what it was told
67    /// when it was issued.
68    pub async fn lookup_lease(&self, lease_id: &str) -> Result<Lease> {
69        let response =
70            self.post("sys/leases/lookup", Json::object([("lease_id", Json::from(lease_id))]))
71                .await?;
72        let data = &response.data;
73
74        let seconds = data.get("ttl").and_then(Json::as_i64).unwrap_or(0).max(0);
75        let renewable = data.get("renewable").and_then(Json::as_bool).unwrap_or(false);
76
77        Ok(Lease::new(lease_id, Duration::from_secs(seconds as u64), renewable))
78    }
79
80    /// Ask for more time on the current token.
81    ///
82    /// The call a long-running service makes on a timer. Losing the token loses
83    /// access to every secret behind it, so this failing is worth an alert
84    /// rather than a log line.
85    pub async fn renew_token(&self, increment: Option<Duration>) -> Result<Lease> {
86        let mut body = Vec::new();
87        if let Some(increment) = increment {
88            body.push(("increment".to_string(), Json::Number(increment.as_secs() as f64)));
89        }
90
91        let response = self.post("auth/token/renew-self", Json::object(body)).await?;
92        Ok(response.lease)
93    }
94
95    /// Give up the current token, and forget it.
96    ///
97    /// Worth calling on a clean shutdown: a token that outlives the process it
98    /// was issued for is a credential nobody is watching. The token is cleared
99    /// here as well, so a later request fails as "no token" rather than as a
100    /// 403 that looks like a policy problem.
101    pub async fn revoke_token(&self) -> Result<()> {
102        let result = self.post("auth/token/revoke-self", Json::Null).await.map(|_| ());
103        self.clear_token();
104        result
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use rustlavel_client::{Fake, FakeResponse};
112
113    fn vault(fake: Fake) -> VaultClient {
114        VaultClient::new("https://vault.test:8200").faking(fake)
115    }
116
117    fn lease() -> Lease {
118        Lease::new("database/creds/app-readwrite/5p1PU", Duration::from_secs(3600), true)
119    }
120
121    #[tokio::test]
122    async fn renewing_a_lease_takes_the_duration_vault_actually_granted() {
123        // Asked for two hours, capped at the role's max. Trusting the request
124        // instead of the answer would leave the process renewing an hour late.
125        let capped = FakeResponse::json(
126            Json::parse(
127                r#"{"lease_id":"database/creds/app-readwrite/5p1PU",
128                    "renewable":true,"lease_duration":3600,"data":null,"auth":null}"#,
129            )
130            .unwrap(),
131        );
132        let client = vault(Fake::new().fallback(capped));
133
134        let renewed =
135            client.renew_lease(&lease(), Some(Duration::from_secs(7200))).await.unwrap();
136
137        assert_eq!(renewed.duration(), Duration::from_secs(3600));
138        assert_eq!(renewed.id(), "database/creds/app-readwrite/5p1PU");
139        assert!(!renewed.should_renew());
140
141        let sent = client.fake().unwrap().recorded()[0].json().unwrap();
142        assert_eq!(sent.get("lease_id").unwrap().as_str(), Some("database/creds/app-readwrite/5p1PU"));
143        assert_eq!(sent.get("increment").unwrap().as_i64(), Some(7200));
144        client.fake().unwrap().assert_sent("/v1/sys/leases/renew");
145    }
146
147    #[tokio::test]
148    async fn revoking_a_lease_sends_its_id_and_accepts_no_content() {
149        let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
150
151        client.revoke_lease(&lease()).await.unwrap();
152
153        client.fake().unwrap().assert_sent("/v1/sys/leases/revoke");
154        let sent = client.fake().unwrap().recorded()[0].json().unwrap();
155        assert!(sent.get("lease_id").is_some());
156    }
157
158    #[tokio::test]
159    async fn a_token_lease_is_sent_to_the_right_endpoint_or_refused() {
160        // A token's lease has no id, and posting an empty one to
161        // `sys/leases/renew` fails with a message about the id rather than
162        // about the token.
163        let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
164
165        let error = client.renew_lease(&Lease::none(), None).await.unwrap_err().to_string();
166        assert!(error.contains("renew_token"), "{error}");
167
168        let error = client.revoke_lease(&Lease::none()).await.unwrap_err().to_string();
169        assert!(error.contains("revoke_token"), "{error}");
170
171        client.fake().unwrap().assert_count(0);
172    }
173
174    #[tokio::test]
175    async fn renewing_the_token_reads_the_lease_back_from_auth() {
176        // `auth/token/renew-self` answers with the lease under `auth`, the same
177        // as a login and unlike `sys/leases/renew`.
178        let renewed = FakeResponse::json(
179            Json::parse(
180                r#"{"lease_id":"","renewable":false,"lease_duration":0,"data":null,
181                    "auth":{"client_token":"s.RpkkBIsBT61V1PTDZGP6eE4q",
182                            "lease_duration":7200,"renewable":true}}"#,
183            )
184            .unwrap(),
185        );
186        let client = vault(Fake::new().fallback(renewed));
187        client.set_token("s.RpkkBIsBT61V1PTDZGP6eE4q");
188
189        let lease = client.renew_token(Some(Duration::from_secs(7200))).await.unwrap();
190
191        assert_eq!(lease.duration(), Duration::from_secs(7200));
192        assert!(lease.renewable());
193        client.fake().unwrap().assert_sent("/v1/auth/token/renew-self");
194    }
195
196    #[tokio::test]
197    async fn revoking_the_token_also_forgets_it() {
198        // Keeping a revoked token would turn every later call into a 403 that
199        // reads like a policy problem.
200        let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
201        client.set_token("s.doomed");
202
203        client.revoke_token().await.unwrap();
204
205        assert!(!client.has_token());
206        client.fake().unwrap().assert_sent("/v1/auth/token/revoke-self");
207    }
208
209    #[tokio::test]
210    async fn looking_a_lease_up_reports_what_the_server_believes() {
211        let body = FakeResponse::json(
212            Json::parse(
213                r#"{"data":{"expire_time":"2026-08-29T14:49:47Z",
214                            "id":"database/creds/app-readwrite/5p1PU",
215                            "issue_time":"2026-08-29T13:49:47Z","last_renewal":null,
216                            "path":"database/creds/app-readwrite","renewable":true,"ttl":3600}}"#,
217            )
218            .unwrap(),
219        );
220        let client = vault(Fake::new().fallback(body));
221
222        let lease = client.lookup_lease("database/creds/app-readwrite/5p1PU").await.unwrap();
223
224        assert_eq!(lease.duration(), Duration::from_secs(3600));
225        assert!(lease.renewable());
226        client.fake().unwrap().assert_sent("/v1/sys/leases/lookup");
227    }
228
229    #[tokio::test]
230    async fn a_revoked_lease_can_no_longer_be_looked_up() {
231        // Observed from OpenBao: a 400 saying "invalid lease", not a 404.
232        let gone = FakeResponse::text(r#"{"errors":["invalid lease"]}"#).status(400);
233        let client = vault(Fake::new().fallback(gone));
234
235        let error = client.lookup_lease("database/creds/app/gone").await.unwrap_err();
236
237        // Vault says "invalid lease" with a 400, not a 404 — a lease that has
238        // been revoked is gone rather than missing, and the message is the part
239        // worth asserting.
240        let error = error.to_string();
241        assert!(error.contains("invalid lease"), "{error}");
242        assert!(!error.contains("nothing is stored at"), "{error}");
243    }
244}