1use 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
24const TOKEN_PLACEHOLDER: &str = "${YOUR_OIDC_TOKEN}";
28
29#[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 workload_identity_provider: String,
44 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FederationConfig {
74 pub issuer: String,
76 pub audience: String,
78 #[serde(flatten)]
79 pub target: ProviderTarget,
80}
81
82#[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 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 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 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 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 #[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}