Skip to main content

secrets_engine_federation/
lib.rs

1//! Federation (shape E) — the engine that deliberately issues nothing.
2//!
3//! Under federation the provider is configured to trust the *consumer's own*
4//! identity, so no credential is minted, brokered, stored or handed over. What
5//! a consumer needs from us is not a secret but an answer to "where do I
6//! exchange my own token, and what must that token say?".
7//!
8//! That makes this engine a directory rather than a vault, and it is the
9//! strongest outcome available: there is no root credential to compromise and
10//! no leased credential to leak. See `docs/delegation/federation.md`.
11
12use async_trait::async_trait;
13use secrets_core::engine::{
14    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
15    SecretsEngine, TtlDoc,
16};
17use secrets_core::mount::ConfigRoleStore;
18use secrets_core::storage::StorageBackend;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21
22const STORE: ConfigRoleStore = ConfigRoleStore::new("federation/config/", "federation/roles/");
23
24/// Placeholder the consumer substitutes with its own workload token. Spelled
25/// loudly because a consumer that sends this literal string has misread the
26/// instructions, and the provider's error would not say so.
27const TOKEN_PLACEHOLDER: &str = "${YOUR_OIDC_TOKEN}";
28
29/// Where a federated identity is being exchanged, and for what.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(tag = "provider", rename_all = "snake_case")]
32pub enum ProviderTarget {
33    Aws {
34        role_arn: String,
35        #[serde(default)]
36        region: Option<String>,
37        #[serde(default)]
38        duration_seconds: Option<i64>,
39    },
40    Gcp {
41        /// Full resource name of the workload identity pool provider,
42        /// `//iam.googleapis.com/projects/…/workloadIdentityPools/…/providers/…`.
43        workload_identity_provider: String,
44        /// Set only when impersonating a service account after the exchange.
45        /// Leaving it unset is the better pattern: granting the federated
46        /// principal IAM directly keeps the original identity in audit logs.
47        #[serde(default)]
48        service_account: Option<String>,
49        #[serde(default = "default_gcp_scope")]
50        scope: String,
51    },
52    Azure {
53        tenant_id: String,
54        client_id: String,
55        #[serde(default = "default_graph_scope")]
56        scope: String,
57    },
58}
59
60fn default_gcp_scope() -> String {
61    "https://www.googleapis.com/auth/cloud-platform".to_string()
62}
63
64fn default_graph_scope() -> String {
65    "https://graph.microsoft.com/.default".to_string()
66}
67
68/// The trust relationship an operator established at the provider. None of
69/// this is secret — it is the *absence* of a secret that makes federation
70/// worth the setup effort — but it lives behind `sudo` with every other
71/// engine's config for consistency.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FederationConfig {
74    /// The consumer's OIDC issuer, exactly as registered at the provider.
75    pub issuer: String,
76    /// The audience the provider was told to require.
77    pub audience: String,
78    #[serde(flatten)]
79    pub target: ProviderTarget,
80}
81
82/// Which consumer may federate where. `subject` is the `sub` claim the
83/// provider's trust policy pins, and pinning it is the whole game: a trust
84/// policy that checks only the issuer lets *any* workload from that issuer in.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct RoleConfig {
87    pub target: String,
88    pub subject: String,
89    #[serde(default)]
90    pub notes: Option<String>,
91}
92
93#[derive(Default)]
94pub struct FederationEngine;
95
96impl FederationEngine {
97    pub fn new() -> Self {
98        Self
99    }
100
101    /// Turns a trust configuration into a request the consumer can actually
102    /// make. This is the engine's entire product.
103    fn instructions(config: &FederationConfig, role: &RoleConfig) -> Value {
104        let required_claims = json!({
105            "iss": config.issuer,
106            "aud": config.audience,
107            "sub": role.subject,
108        });
109
110        let exchange = match &config.target {
111            ProviderTarget::Aws {
112                role_arn,
113                region,
114                duration_seconds,
115            } => {
116                let host = region
117                    .as_deref()
118                    .map(|r| format!("https://sts.{r}.amazonaws.com/"))
119                    .unwrap_or_else(|| "https://sts.amazonaws.com/".to_string());
120                json!({
121                    "method": "POST",
122                    "url": host,
123                    "form": {
124                        "Action": "AssumeRoleWithWebIdentity",
125                        "Version": "2011-06-15",
126                        "RoleArn": role_arn,
127                        "RoleSessionName": role.subject,
128                        "WebIdentityToken": TOKEN_PLACEHOLDER,
129                        "DurationSeconds": duration_seconds.unwrap_or(900).to_string(),
130                    },
131                    "returns": "AccessKeyId, SecretAccessKey, SessionToken, Expiration",
132                    "note": "This call needs no AWS credential — only your own JWT.",
133                })
134            }
135            ProviderTarget::Gcp {
136                workload_identity_provider,
137                service_account,
138                scope,
139            } => {
140                let mut steps = vec![json!({
141                    "step": 1,
142                    "method": "POST",
143                    "url": "https://sts.googleapis.com/v1/token",
144                    "json": {
145                        "grantType": "urn:ietf:params:oauth:grant-type:token-exchange",
146                        "audience": workload_identity_provider,
147                        "scope": scope,
148                        "requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
149                        "subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
150                        "subjectToken": TOKEN_PLACEHOLDER,
151                    },
152                    "returns": "access_token — usable directly if the federated principal holds IAM",
153                })];
154                if let Some(service_account) = service_account {
155                    steps.push(json!({
156                        "step": 2,
157                        "method": "POST",
158                        "url": format!(
159                            "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{service_account}:generateAccessToken"
160                        ),
161                        "authorization": "Bearer <the access_token from step 1>",
162                        "json": { "scope": [scope], "lifetime": "900s" },
163                        "note": "Only needed when impersonating. Granting the federated \
164                                 principal IAM directly is preferable — it keeps your own \
165                                 identity in Cloud Storage audit logs instead of hiding it \
166                                 behind a service account.",
167                    }));
168                }
169                json!({ "steps": steps })
170            }
171            ProviderTarget::Azure {
172                tenant_id,
173                client_id,
174                scope,
175            } => json!({
176                "method": "POST",
177                "url": format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"),
178                "form": {
179                    "grant_type": "client_credentials",
180                    "client_id": client_id,
181                    "client_assertion_type":
182                        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
183                    "client_assertion": TOKEN_PLACEHOLDER,
184                    "scope": scope,
185                },
186                "returns": "access_token, expires_in",
187                "note": "No client_secret appears in this request. That is the point.",
188            }),
189        };
190
191        json!({
192            "shape": CredentialShape::Federation,
193            "issued_credential": Value::Null,
194            "your_token_must_present": required_claims,
195            "exchange": exchange,
196            "reminder": format!(
197                "Replace {TOKEN_PLACEHOLDER} with the JWT your own platform issues. \
198                 This server never sees it and holds no credential for this provider."
199            ),
200        })
201    }
202}
203
204#[async_trait]
205impl SecretsEngine for FederationEngine {
206    fn doc(&self) -> EngineDoc {
207        EngineDoc {
208            provider: "AWS, Google Cloud and Microsoft Entra ID".to_string(),
209            mechanism: "publishes the OIDC token-exchange instructions a consumer \
210                        needs to authenticate to a provider with its own workload \
211                        identity. Nothing is minted, stored or handed over."
212                .to_string(),
213            shape: CredentialShape::Federation,
214            revocable: false,
215            revoke_effect: "nothing to revoke — this engine never issues a credential. \
216                            Access is withdrawn at the provider by removing the trust \
217                            policy condition or the IAM grant for that subject, which \
218                            takes effect on the consumer's next exchange."
219                .to_string(),
220            ttl: TtlDoc {
221                min_seconds: None,
222                max_seconds: None,
223                fixed: false,
224                note: "not applicable to the instructions, which are not a secret. The \
225                       credential the consumer obtains for itself is governed by the \
226                       provider — 15 minutes to 12 hours for AWS STS, up to 1 hour for \
227                       Google, 60–90 minutes for Entra."
228                    .to_string(),
229            },
230            scoping: "at the provider, by pinning the trust policy to one issuer, one \
231                      audience and one exact subject, then attaching a least-privilege \
232                      policy to the federated principal."
233                .to_string(),
234            root_credential: "none. This is the only engine here that stores no \
235                              provider secret whatsoever, which is why it is worth \
236                              preferring wherever a provider supports it."
237                .to_string(),
238            paths: vec![
239                PathDoc::new(
240                    "federation/config/{target}",
241                    &["POST", "GET", "DELETE"],
242                    "sudo",
243                    "register a provider trust relationship: issuer, audience and the \
244                     AWS role / GCP pool provider / Entra app it maps to",
245                ),
246                PathDoc::new(
247                    "federation/roles/{role}",
248                    &["GET"],
249                    "read",
250                    "**the consumer-facing route**: returns the exchange instructions \
251                     and the claims your own token must carry. No credential is issued, \
252                     so there is nothing to lease.",
253                ),
254                PathDoc::new(
255                    "federation/roles/{role}",
256                    &["POST", "DELETE"],
257                    "create / sudo",
258                    "define which consumer subject may federate to which target",
259                ),
260                PathDoc::new(
261                    "federation/creds/{role}",
262                    &["GET"],
263                    "read",
264                    "refuses, by design — see the role path above",
265                ),
266                PathDoc::new("federation/help", &["GET"], "authenticated", "this document"),
267            ],
268            docs_url: Some("docs/delegation/federation.md".to_string()),
269            caveats: vec![
270                "Pin the trust policy's subject to the exact workload. A wildcard \
271                 subject, or a condition on audience alone, lets any identity from that \
272                 issuer assume the role — the classic confused-deputy misconfiguration."
273                    .to_string(),
274                "Neither GitHub nor GitLab can be reached this way: their OIDC tokens \
275                 authenticate a workflow *outward* to third parties, and no endpoint \
276                 exchanges an external token for their own API access."
277                    .to_string(),
278                "Withdrawal is not instant for credentials the consumer already holds. \
279                 Removing the trust stops the next exchange; an STS session or Google \
280                 access token already issued runs until it expires."
281                    .to_string(),
282                "The consumer needs an OIDC identity of its own. Without one — no \
283                 Kubernetes projected token, no cloud workload identity — federation is \
284                 unavailable and a brokered engine is the fallback."
285                    .to_string(),
286            ],
287        }
288    }
289
290    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<Value> {
291        // Roles get the full treatment: the stored definition plus resolved,
292        // copy-pasteable exchange instructions. This is the one route
293        // consumers actually call.
294        if let Some(name) = path.strip_prefix("roles/") {
295            let role: RoleConfig = STORE.require_role(storage, name).await?;
296            let config: FederationConfig = STORE.require_config(storage, &role.target).await?;
297            let mut value = json!({
298                "role": name,
299                "target": role.target,
300                "subject": role.subject,
301                "help": "/v1/federation/help",
302            });
303            if let Some(notes) = &role.notes {
304                value["notes"] = json!(notes);
305            }
306            if let (Some(object), Value::Object(instructions)) =
307                (value.as_object_mut(), Self::instructions(&config, &role))
308            {
309                object.extend(instructions);
310            }
311            return Ok(value);
312        }
313        STORE.handle_read::<RoleConfig>(storage, path).await
314    }
315
316    async fn write(
317        &self,
318        storage: &dyn StorageBackend,
319        path: &str,
320        data: Value,
321    ) -> EngineResult<()> {
322        STORE
323            .handle_write::<FederationConfig, RoleConfig>(storage, path, data)
324            .await
325    }
326
327    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
328        STORE.handle_delete(storage, path).await
329    }
330
331    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
332        STORE.handle_list(storage, prefix).await
333    }
334
335    /// Refuses, and explains itself. A consumer following the
336    /// `{mount}/creds/{role}` convention deserves to be told why federation
337    /// breaks it rather than being handed something credential-shaped.
338    async fn generate(
339        &self,
340        _storage: &dyn StorageBackend,
341        role: &str,
342    ) -> EngineResult<GeneratedCredential> {
343        Err(EngineError::InvalidRequest(format!(
344            "federation issues no credential, so there is nothing to lease. \
345             GET /v1/federation/roles/{role} for the exchange instructions, then \
346             present your own OIDC token to the provider. See /v1/federation/help."
347        )))
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn role() -> RoleConfig {
356        RoleConfig {
357            target: "prod".to_string(),
358            subject: "system:serviceaccount:apps:report-service".to_string(),
359            notes: None,
360        }
361    }
362
363    fn config(target: ProviderTarget) -> FederationConfig {
364        FederationConfig {
365            issuer: "https://oidc.example.com".to_string(),
366            audience: "sts.amazonaws.com".to_string(),
367            target,
368        }
369    }
370
371    #[test]
372    fn aws_instructions_need_no_aws_credential() {
373        let value = FederationEngine::instructions(
374            &config(ProviderTarget::Aws {
375                role_arn: "arn:aws:iam::123456789012:role/reports".to_string(),
376                region: Some("eu-west-3".to_string()),
377                duration_seconds: Some(900),
378            }),
379            &role(),
380        );
381        let form = &value["exchange"]["form"];
382        assert_eq!(form["Action"], "AssumeRoleWithWebIdentity");
383        assert_eq!(form["WebIdentityToken"], TOKEN_PLACEHOLDER);
384        assert_eq!(value["exchange"]["url"], "https://sts.eu-west-3.amazonaws.com/");
385        // The defining property of shape E: nothing was issued.
386        assert!(value["issued_credential"].is_null());
387    }
388
389    #[test]
390    fn gcp_instructions_skip_impersonation_when_not_configured() {
391        let direct = FederationEngine::instructions(
392            &config(ProviderTarget::Gcp {
393                workload_identity_provider: "//iam.googleapis.com/projects/1/locations/global/workloadIdentityPools/p/providers/v".to_string(),
394                service_account: None,
395                scope: default_gcp_scope(),
396            }),
397            &role(),
398        );
399        assert_eq!(
400            direct["exchange"]["steps"].as_array().map(Vec::len),
401            Some(1),
402            "granting the federated principal directly should need one step"
403        );
404
405        let impersonated = FederationEngine::instructions(
406            &config(ProviderTarget::Gcp {
407                workload_identity_provider: "//iam.googleapis.com/x".to_string(),
408                service_account: Some("reports@acme.iam.gserviceaccount.com".to_string()),
409                scope: default_gcp_scope(),
410            }),
411            &role(),
412        );
413        assert_eq!(impersonated["exchange"]["steps"].as_array().map(Vec::len), Some(2));
414    }
415
416    #[test]
417    fn azure_instructions_carry_no_client_secret() {
418        let value = FederationEngine::instructions(
419            &config(ProviderTarget::Azure {
420                tenant_id: "tenant".to_string(),
421                client_id: "client".to_string(),
422                scope: default_graph_scope(),
423            }),
424            &role(),
425        );
426        let form = &value["exchange"]["form"];
427        assert_eq!(form["client_assertion"], TOKEN_PLACEHOLDER);
428        assert!(
429            form.get("client_secret").is_none(),
430            "a federated exchange must never carry a client secret"
431        );
432    }
433
434    /// The claims block is what stops an operator pinning the trust policy to
435    /// the issuer alone, so it must always name all three.
436    #[test]
437    fn instructions_always_state_the_required_claims() {
438        let value = FederationEngine::instructions(
439            &config(ProviderTarget::Azure {
440                tenant_id: "t".to_string(),
441                client_id: "c".to_string(),
442                scope: default_graph_scope(),
443            }),
444            &role(),
445        );
446        let claims = &value["your_token_must_present"];
447        assert_eq!(claims["iss"], "https://oidc.example.com");
448        assert_eq!(claims["aud"], "sts.amazonaws.com");
449        assert_eq!(claims["sub"], "system:serviceaccount:apps:report-service");
450    }
451
452    #[tokio::test]
453    async fn generate_refuses_and_points_at_the_roles_path() {
454        let engine = FederationEngine::new();
455        struct NoStorage;
456        #[async_trait]
457        impl StorageBackend for NoStorage {
458            async fn get(
459                &self,
460                _: &str,
461            ) -> secrets_core::storage::StorageResult<Option<secrets_core::storage::StorageEntry>> {
462                Ok(None)
463            }
464            async fn put(
465                &self,
466                _: &str,
467                _: secrets_core::storage::StorageEntry,
468            ) -> secrets_core::storage::StorageResult<()> {
469                Ok(())
470            }
471            async fn delete(&self, _: &str) -> secrets_core::storage::StorageResult<()> {
472                Ok(())
473            }
474            async fn list(&self, _: &str) -> secrets_core::storage::StorageResult<Vec<String>> {
475                Ok(vec![])
476            }
477        }
478        let err = engine.generate(&NoStorage, "report-service").await.unwrap_err();
479        let message = err.to_string();
480        assert!(message.contains("federation/roles/report-service"), "{message}");
481        assert!(message.contains("nothing to lease"), "{message}");
482    }
483
484    #[test]
485    fn doc_agrees_with_its_shape() {
486        let doc = FederationEngine::new().doc();
487        assert_eq!(doc.shape, CredentialShape::Federation);
488        assert_eq!(doc.revocable, doc.shape.revocable());
489        assert!(doc.root_credential.contains("none"));
490    }
491}