dynamic_config_consul/auth.rs
1//! Getting an ACL token, and getting another one when it stops working.
2//!
3//! Consul's story is shorter than Vault's. A token is either handed to the
4//! process — the usual case, through `CONSUL_HTTP_TOKEN` — or obtained by
5//! presenting a bearer token to an *auth method*, which is how a workload in
6//! Kubernetes proves who it is without a secret to distribute.
7//!
8//! There is no renewal. Consul issues login tokens with an expiry and expects
9//! you to log in again, so that is what happens: a token close to expiry is
10//! replaced, and a `403` replaces one early. Both paths exist for the same
11//! reason they do in the Vault crate — the proactive one should normally fire,
12//! and the reactive one covers clock skew and tokens revoked out from under a
13//! running process.
14//!
15//! *When* a token is close enough to expiry to replace is
16//! [`Cached`](dynamic_config_store_core::credential::Cached)'s decision, shared
17//! with the Vault and Firestore crates. Because there is no renewal to choose
18//! between, nothing else of this store's token handling is left to decide:
19//! [`Consul`](crate::Consul) hands `Cached` a closure that logs in, and that
20//! is the whole of it.
21
22use dynamic_config::Error;
23
24/// Where a Kubernetes service-account token is mounted, by convention.
25pub const SERVICE_ACCOUNT_TOKEN: &str =
26 dynamic_config_store_core::credential::SERVICE_ACCOUNT_TOKEN;
27
28/// How to obtain a Consul ACL token.
29#[derive(Clone)]
30#[non_exhaustive]
31pub enum Auth {
32 /// No token at all.
33 ///
34 /// Correct for a Consul with ACLs disabled, and for a `default` policy that
35 /// allows reads — both of which are ordinary in development.
36 Anonymous,
37
38 /// A token somebody already obtained, usually `CONSUL_HTTP_TOKEN`.
39 ///
40 /// The only variant that cannot recover on its own: there are no
41 /// credentials here to log in again with.
42 Token(String),
43
44 /// A bearer token presented to an auth method.
45 ///
46 /// Consul calls the endpoint `/v1/acl/login`; the method decides what a
47 /// valid bearer token looks like — a Kubernetes service-account JWT, an
48 /// OIDC id token, a JWT signed by something Consul trusts.
49 Login {
50 /// The auth method's name, as configured in Consul.
51 method: String,
52 /// Where the bearer token comes from.
53 bearer: Bearer,
54 /// Consul's `Meta`, attached to the issued token for auditing.
55 meta: Vec<(String, String)>,
56 },
57}
58
59/// Where a bearer token comes from.
60#[derive(Clone)]
61#[non_exhaustive]
62pub enum Bearer {
63 /// A literal token.
64 Literal(String),
65 /// A file, re-read at every login.
66 ///
67 /// This is what a Kubernetes projected service-account token needs: the
68 /// kubelet rotates it, and a copy taken at startup expires with the pod
69 /// still running.
70 File(String),
71}
72
73impl Auth {
74 /// A token somebody already obtained.
75 pub fn token(token: impl Into<String>) -> Self {
76 Self::Token(token.into())
77 }
78
79 /// `CONSUL_HTTP_TOKEN`, if it is set and not empty.
80 ///
81 /// Returns [`Auth::Anonymous`] when it is not, because a Consul with ACLs
82 /// disabled is a perfectly ordinary thing to point this at, and failing
83 /// would make the convenience useless in exactly that case.
84 #[must_use]
85 pub fn from_environment() -> Self {
86 match std::env::var("CONSUL_HTTP_TOKEN") {
87 Ok(token) if !token.is_empty() => Self::Token(token),
88 _ => Self::Anonymous,
89 }
90 }
91
92 /// Kubernetes: the pod's service-account token, presented to `method`.
93 pub fn kubernetes(method: impl Into<String>) -> Self {
94 Self::Login {
95 method: method.into(),
96 bearer: Bearer::File(SERVICE_ACCOUNT_TOKEN.to_owned()),
97 meta: Vec::new(),
98 }
99 }
100
101 /// A JWT or OIDC token presented to `method`.
102 pub fn jwt(method: impl Into<String>, token: impl Into<String>) -> Self {
103 Self::Login {
104 method: method.into(),
105 bearer: Bearer::Literal(token.into()),
106 meta: Vec::new(),
107 }
108 }
109
110 /// Reads the bearer token from somewhere other than the conventional path.
111 #[must_use]
112 pub fn with_bearer_file(mut self, path: impl Into<String>) -> Self {
113 if let Self::Login { bearer, .. } = &mut self {
114 *bearer = Bearer::File(path.into());
115 }
116
117 self
118 }
119
120 /// Adds a `Meta` entry, which Consul attaches to the issued token.
121 #[must_use]
122 pub fn with_meta(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
123 if let Self::Login { meta, .. } = &mut self {
124 meta.push((name.into(), value.into()));
125 }
126
127 self
128 }
129
130 /// The body to POST to `/v1/acl/login`, or `None` when there is no login.
131 ///
132 /// # Errors
133 ///
134 /// If a bearer token file cannot be read.
135 pub(crate) fn login_body(&self) -> Result<Option<serde_json::Value>, Error> {
136 let Self::Login {
137 method,
138 bearer,
139 meta,
140 } = self
141 else {
142 return Ok(None);
143 };
144
145 let token = match bearer {
146 Bearer::Literal(token) => token.clone(),
147 Bearer::File(path) => std::fs::read_to_string(path)
148 .map_err(|error| {
149 Error::remote(format!(
150 "consul: cannot read the bearer token at {path}: {error}"
151 ))
152 })?
153 .trim()
154 .to_owned(),
155 };
156
157 let meta: serde_json::Map<String, serde_json::Value> = meta
158 .iter()
159 .map(|(name, value)| (name.clone(), serde_json::Value::from(value.clone())))
160 .collect();
161
162 Ok(Some(serde_json::json!({
163 "AuthMethod": method,
164 "BearerToken": token,
165 "Meta": meta,
166 })))
167 }
168
169 /// How to name this method in an error.
170 pub(crate) fn describe(&self) -> String {
171 match self {
172 Self::Anonymous => "no token".to_owned(),
173 Self::Token(_) => "a supplied token".to_owned(),
174 Self::Login { method, .. } => format!("auth method `{method}`"),
175 }
176 }
177}
178
179// Debug is hand-written for every type on this page that can hold a secret:
180// a derive prints payloads, and the payloads here are ACL tokens. What IS
181// printed — variant names, method names, expiry — is what a person debugging
182// auth actually needs.
183impl std::fmt::Debug for Auth {
184 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185 match self {
186 Self::Anonymous => f.write_str("Anonymous"),
187 Self::Token(_) => f.write_str("Token(***)"),
188 Self::Login { method, meta, .. } => f
189 .debug_struct("Login")
190 .field("method", method)
191 .field("meta", meta)
192 .finish_non_exhaustive(),
193 }
194 }
195}
196
197impl std::fmt::Debug for Bearer {
198 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199 match self {
200 Self::Literal(_) => f.write_str("Literal(***)"),
201 // The path is not a secret — the token *behind* it is, and it is
202 // never held here.
203 Self::File(path) => f.debug_tuple("File").field(path).finish(),
204 }
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn a_supplied_token_needs_no_login() {
214 assert!(Auth::token("t").login_body().unwrap().is_none());
215 assert!(Auth::Anonymous.login_body().unwrap().is_none());
216 }
217
218 #[test]
219 fn a_login_presents_its_bearer_token_to_a_named_method() {
220 let body = Auth::jwt("kubernetes", "a.b.c")
221 .login_body()
222 .unwrap()
223 .expect("this one logs in");
224
225 assert_eq!(body["AuthMethod"], "kubernetes");
226 assert_eq!(body["BearerToken"], "a.b.c");
227 }
228
229 #[test]
230 fn meta_is_carried_through_for_the_audit_log() {
231 let body = Auth::jwt("kubernetes", "a.b.c")
232 .with_meta("pod", "myapp-7f9")
233 .login_body()
234 .unwrap()
235 .unwrap();
236
237 assert_eq!(body["Meta"]["pod"], "myapp-7f9");
238 }
239
240 #[test]
241 fn a_missing_bearer_file_says_where_it_looked() {
242 let error = Auth::kubernetes("kubernetes")
243 .with_bearer_file("/no/such/token")
244 .login_body()
245 .expect_err("there is no token there");
246
247 assert!(error.to_string().contains("/no/such/token"), "{error}");
248 }
249
250 #[test]
251 fn an_unset_environment_variable_is_anonymous_rather_than_an_error() {
252 // Consul with ACLs off is an ordinary thing to point this at, so the
253 // convenience has to work there.
254 std::env::remove_var("CONSUL_HTTP_TOKEN");
255
256 assert!(matches!(Auth::from_environment(), Auth::Anonymous));
257 }
258
259 // When a token is stale, what a TTL too large to represent means, and
260 // that a login happens once rather than per request, are
261 // `dynamic-config-store-core`'s tests now: they were the same assertions
262 // here, in the Vault crate and in the Firestore crate, over the same
263 // code. What is Consul's alone — that a login is *reached* only for
264 // `Auth::Login`, and never for a supplied or absent token — is
265 // `mock_agent.rs`.
266}