Skip to main content

fnox_core/lease_backends/
mod.rs

1use crate::error::Result;
2use async_trait::async_trait;
3use indexmap::IndexMap;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::time::Duration;
7
8pub mod aws_sts;
9pub mod azure_token;
10pub mod cloudflare;
11pub mod command;
12pub mod gcp_iam;
13pub mod github_app;
14pub mod github_oauth;
15pub mod vault;
16
17/// A credential lease with metadata for tracking and revocation
18#[derive(Debug, Clone)]
19pub struct Lease {
20    /// The credentials (provider-specific format, e.g. AWS_ACCESS_KEY_ID -> value)
21    pub credentials: IndexMap<String, String>,
22    /// When this lease expires (None = no automatic expiry)
23    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
24    /// Lease ID for tracking/revocation
25    pub lease_id: String,
26}
27
28/// Lease backend capability for vending short-lived credentials
29#[async_trait]
30pub trait LeaseBackend: Send + Sync {
31    /// Create a short-lived credential
32    async fn create_lease(&self, duration: Duration, label: &str) -> Result<Lease>;
33
34    /// Revoke a previously issued lease (for cleanup).
35    /// `credentials` contains the cached credential values (decrypted) when
36    /// available — backends that need a credential value for revocation (e.g.
37    /// GitHub App, which must authenticate DELETE with the token itself) can
38    /// look it up here instead of storing secrets in `lease_id`.
39    async fn revoke_lease(
40        &self,
41        _lease_id: &str,
42        _credentials: Option<&IndexMap<String, String>>,
43    ) -> Result<()> {
44        // Default: no-op (for backends with native TTL)
45        Ok(())
46    }
47
48    /// Maximum allowed lease duration
49    fn max_lease_duration(&self) -> Duration;
50}
51
52fn default_gcp_scopes() -> Vec<String> {
53    vec!["https://www.googleapis.com/auth/cloud-platform".to_string()]
54}
55
56fn default_command_timeout() -> String {
57    "30s".to_string()
58}
59
60fn default_gcp_env_var() -> String {
61    "CLOUDSDK_AUTH_ACCESS_TOKEN".to_string()
62}
63
64fn default_vault_method() -> String {
65    "get".to_string()
66}
67
68fn default_azure_env_var() -> String {
69    "AZURE_ACCESS_TOKEN".to_string()
70}
71
72fn default_cloudflare_env_var() -> String {
73    "CLOUDFLARE_API_TOKEN".to_string()
74}
75
76fn default_github_env_var() -> String {
77    "GITHUB_TOKEN".to_string()
78}
79
80fn default_github_oauth_auth_base() -> String {
81    "https://github.com/login/oauth".to_string()
82}
83
84fn default_github_oauth_api_base() -> String {
85    "https://api.github.com".to_string()
86}
87
88fn default_github_oauth_scope() -> String {
89    "repo read:org workflow".to_string()
90}
91
92fn default_github_oauth_keyring_service() -> String {
93    "fnox-github-oauth".to_string()
94}
95
96fn default_true() -> bool {
97    true
98}
99
100/// Generate a unique lease ID with a prefix.
101/// Appends a random suffix to avoid collisions between concurrent invocations.
102pub fn generate_lease_id(prefix: &str) -> String {
103    let suffix: u64 = rand::random();
104    format!("{prefix}-{suffix:016x}")
105}
106
107/// Configuration for a lease backend (manually defined, no codegen)
108#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109#[serde(tag = "type", rename_all = "kebab-case")]
110pub enum LeaseBackendConfig {
111    /// AWS STS AssumeRole
112    AwsSts {
113        region: String,
114        #[serde(skip_serializing_if = "Option::is_none")]
115        profile: Option<String>,
116        role_arn: String,
117        #[serde(skip_serializing_if = "Option::is_none")]
118        endpoint: Option<String>,
119        #[serde(skip_serializing_if = "Option::is_none")]
120        duration: Option<String>,
121    },
122    /// GCP Service Account Impersonation
123    GcpIam {
124        service_account_email: String,
125        #[serde(default = "default_gcp_scopes")]
126        scopes: Vec<String>,
127        #[serde(default = "default_gcp_env_var")]
128        env_var: String,
129        #[serde(skip_serializing_if = "Option::is_none")]
130        duration: Option<String>,
131    },
132    /// HashiCorp Vault Dynamic Secrets
133    Vault {
134        #[serde(skip_serializing_if = "Option::is_none")]
135        address: Option<String>,
136        #[serde(skip_serializing_if = "Option::is_none")]
137        token: Option<String>,
138        secret_path: String,
139        #[serde(skip_serializing_if = "Option::is_none")]
140        namespace: Option<String>,
141        env_map: IndexMap<String, String>,
142        #[serde(skip_serializing_if = "Option::is_none")]
143        duration: Option<String>,
144        /// HTTP method: "get" (default) or "post" (required for pki/issue and some engines)
145        #[serde(default = "default_vault_method")]
146        method: String,
147    },
148    /// Azure Token Acquisition
149    AzureToken {
150        scope: String,
151        #[serde(default = "default_azure_env_var")]
152        env_var: String,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        duration: Option<String>,
155    },
156    /// Cloudflare API Token
157    Cloudflare {
158        /// Token type: "user" (default) or "account"
159        #[serde(default)]
160        token_type: cloudflare::CloudflareTokenType,
161        #[serde(skip_serializing_if = "Option::is_none")]
162        account_id: Option<String>,
163        #[serde(default, skip_serializing_if = "Option::is_none")]
164        policies: Option<Vec<cloudflare::CloudflarePolicy>>,
165        #[serde(default = "default_cloudflare_env_var")]
166        env_var: String,
167        #[serde(skip_serializing_if = "Option::is_none")]
168        duration: Option<String>,
169    },
170    /// GitHub App Installation Token
171    GithubApp {
172        app_id: String,
173        installation_id: String,
174        #[serde(skip_serializing_if = "Option::is_none")]
175        private_key_file: Option<String>,
176        #[serde(default = "default_github_env_var")]
177        env_var: String,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        permissions: Option<IndexMap<String, String>>,
180        #[serde(skip_serializing_if = "Option::is_none")]
181        repositories: Option<Vec<String>>,
182        /// GitHub API base URL (default: https://api.github.com)
183        #[serde(skip_serializing_if = "Option::is_none")]
184        api_base: Option<String>,
185        #[serde(skip_serializing_if = "Option::is_none")]
186        duration: Option<String>,
187    },
188    /// GitHub App User Access Token via OAuth Device Flow
189    GithubOauth {
190        client_id: String,
191        /// OAuth scope string requested from GitHub.
192        #[serde(default = "default_github_oauth_scope")]
193        scope: String,
194        #[serde(default = "default_github_env_var")]
195        env_var: String,
196        /// OS keyring service used to cache access/refresh tokens.
197        #[serde(default = "default_github_oauth_keyring_service")]
198        keyring_service: String,
199        /// Disable to force device flow on each lease creation.
200        #[serde(default = "default_true")]
201        keyring_cache: bool,
202        /// Open the verification URL in a browser when possible.
203        #[serde(default = "default_true")]
204        open_browser: bool,
205        /// OAuth token endpoint base URL (default: https://github.com/login/oauth)
206        #[serde(default = "default_github_oauth_auth_base")]
207        auth_base: String,
208        /// GitHub API base URL (default: https://api.github.com)
209        #[serde(default = "default_github_oauth_api_base")]
210        api_base: String,
211        #[serde(skip_serializing_if = "Option::is_none")]
212        duration: Option<String>,
213    },
214    /// Generic Command Backend
215    Command {
216        create_command: String,
217        #[serde(skip_serializing_if = "Option::is_none")]
218        revoke_command: Option<String>,
219        #[serde(skip_serializing_if = "Option::is_none")]
220        duration: Option<String>,
221        /// Timeout for command execution (e.g., "30s", "2m"; default: "30s")
222        #[serde(default = "default_command_timeout")]
223        timeout: String,
224    },
225}
226
227impl LeaseBackendConfig {
228    /// Check if the prerequisites for this backend are available.
229    /// Returns a human-readable message describing what's missing, or None if ready.
230    pub fn check_prerequisites(&self) -> Option<String> {
231        match self {
232            LeaseBackendConfig::AwsSts { profile, .. } => aws_sts::check_prerequisites(profile),
233            LeaseBackendConfig::GcpIam { .. } => gcp_iam::check_prerequisites(),
234            LeaseBackendConfig::Vault { address, token, .. } => {
235                vault::check_prerequisites(address, token)
236            }
237            LeaseBackendConfig::AzureToken { .. } => azure_token::check_prerequisites(),
238            LeaseBackendConfig::Cloudflare { .. } => cloudflare::check_prerequisites(),
239            LeaseBackendConfig::GithubApp {
240                private_key_file, ..
241            } => github_app::check_prerequisites(private_key_file),
242            LeaseBackendConfig::GithubOauth { .. } => github_oauth::check_prerequisites(),
243            LeaseBackendConfig::Command { .. } => command::check_prerequisites(),
244        }
245    }
246
247    /// Returns a list of (env_var_name, description) pairs for env vars the user
248    /// can set to satisfy prerequisites. Used by `fnox lease create` to prompt
249    /// interactively for missing credentials.
250    pub fn required_env_vars(&self) -> Vec<(&'static str, &'static str)> {
251        match self {
252            LeaseBackendConfig::AwsSts { .. } => aws_sts::required_env_vars(),
253            LeaseBackendConfig::GcpIam { .. } => gcp_iam::required_env_vars(),
254            LeaseBackendConfig::Vault { address, token, .. } => {
255                vault::required_env_vars(address, token)
256            }
257            LeaseBackendConfig::AzureToken { .. } => azure_token::required_env_vars(),
258            LeaseBackendConfig::Cloudflare { .. } => cloudflare::required_env_vars(),
259            LeaseBackendConfig::GithubApp { .. } => github_app::required_env_vars(),
260            LeaseBackendConfig::GithubOauth { .. } => github_oauth::required_env_vars(),
261            LeaseBackendConfig::Command { .. } => command::required_env_vars(),
262        }
263    }
264
265    /// Zero-allocation check whether this backend produces the given env var key.
266    pub fn produces_env_var(&self, key: &str) -> bool {
267        match self {
268            LeaseBackendConfig::AwsSts { .. } => aws_sts::PRODUCED_ENV_VARS.contains(&key),
269            LeaseBackendConfig::GcpIam { env_var, .. } => env_var == key,
270            LeaseBackendConfig::Vault { env_map, .. } => env_map.values().any(|v| v == key),
271            LeaseBackendConfig::AzureToken { env_var, .. } => env_var == key,
272            LeaseBackendConfig::Command { .. } => false,
273            LeaseBackendConfig::Cloudflare { env_var, .. } => env_var == key,
274            LeaseBackendConfig::GithubApp { env_var, .. } => env_var == key,
275            LeaseBackendConfig::GithubOauth { env_var, .. } => env_var == key,
276        }
277    }
278
279    /// All env var names this backend may consume at runtime, including aliases.
280    /// Used by `fnox get` to filter which profile secrets to resolve before
281    /// creating a lease. Each backend defines its own `CONSUMED_ENV_VARS` constant
282    /// covering both canonical names and runtime aliases.
283    pub fn consumed_env_vars(&self) -> &'static [&'static str] {
284        match self {
285            LeaseBackendConfig::AwsSts { .. } => aws_sts::CONSUMED_ENV_VARS,
286            LeaseBackendConfig::GcpIam { .. } => gcp_iam::CONSUMED_ENV_VARS,
287            LeaseBackendConfig::Vault { .. } => vault::CONSUMED_ENV_VARS,
288            LeaseBackendConfig::AzureToken { .. } => azure_token::CONSUMED_ENV_VARS,
289            LeaseBackendConfig::Command { .. } => command::CONSUMED_ENV_VARS,
290            LeaseBackendConfig::Cloudflare { .. } => cloudflare::CONSUMED_ENV_VARS,
291            LeaseBackendConfig::GithubApp { .. } => github_app::CONSUMED_ENV_VARS,
292            LeaseBackendConfig::GithubOauth { .. } => github_oauth::CONSUMED_ENV_VARS,
293        }
294    }
295
296    /// Create a lease backend instance from this configuration
297    pub fn create_backend(&self) -> Result<Box<dyn LeaseBackend>> {
298        match self {
299            LeaseBackendConfig::AwsSts {
300                region,
301                profile,
302                role_arn,
303                endpoint,
304                ..
305            } => Ok(Box::new(aws_sts::AwsStsBackend::new(
306                region.clone(),
307                profile.clone(),
308                role_arn.clone(),
309                endpoint.clone(),
310            ))),
311            LeaseBackendConfig::GcpIam {
312                service_account_email,
313                scopes,
314                env_var,
315                ..
316            } => Ok(Box::new(gcp_iam::GcpIamBackend::new(
317                service_account_email.clone(),
318                scopes.clone(),
319                env_var.clone(),
320            ))),
321            LeaseBackendConfig::Vault {
322                address,
323                token,
324                secret_path,
325                namespace,
326                env_map,
327                method,
328                ..
329            } => Ok(Box::new(vault::VaultBackend::new(
330                address.clone(),
331                token.clone(),
332                secret_path.clone(),
333                namespace.clone(),
334                env_map.clone(),
335                method.clone(),
336            )?)),
337            LeaseBackendConfig::AzureToken { scope, env_var, .. } => Ok(Box::new(
338                azure_token::AzureTokenBackend::new(scope.clone(), env_var.clone()),
339            )),
340            LeaseBackendConfig::Cloudflare {
341                token_type,
342                account_id,
343                policies,
344                env_var,
345                ..
346            } => Ok(Box::new(cloudflare::CloudflareBackend::new(
347                token_type.clone(),
348                account_id.clone(),
349                policies.clone(),
350                env_var.clone(),
351            )?)),
352            LeaseBackendConfig::GithubApp {
353                app_id,
354                installation_id,
355                private_key_file,
356                env_var,
357                permissions,
358                repositories,
359                api_base,
360                ..
361            } => Ok(Box::new(github_app::GitHubAppBackend::new(
362                app_id.clone(),
363                installation_id.clone(),
364                private_key_file.clone(),
365                env_var.clone(),
366                permissions.clone(),
367                repositories.clone(),
368                api_base.clone(),
369            ))),
370            LeaseBackendConfig::GithubOauth {
371                client_id,
372                scope,
373                env_var,
374                keyring_service,
375                keyring_cache,
376                open_browser,
377                auth_base,
378                api_base,
379                ..
380            } => Ok(Box::new(github_oauth::GitHubOauthBackend::new(
381                client_id.clone(),
382                scope.clone(),
383                env_var.clone(),
384                keyring_service.clone(),
385                *keyring_cache,
386                *open_browser,
387                auth_base.clone(),
388                api_base.clone(),
389            ))),
390            LeaseBackendConfig::Command {
391                create_command,
392                revoke_command,
393                timeout,
394                ..
395            } => {
396                let timeout = crate::lease::parse_duration(timeout)?;
397                Ok(Box::new(command::CommandBackend::new(
398                    create_command.clone(),
399                    revoke_command.clone(),
400                    timeout,
401                )))
402            }
403        }
404    }
405
406    /// Compute a stable hash of security-relevant backend configuration.
407    /// Used to detect config changes and invalidate cached lease credentials.
408    /// Excludes `duration` and `timeout` since changing these doesn't invalidate
409    /// existing cached credentials (e.g., switching from "1h" to "2h" shouldn't
410    /// force a fresh lease when cached credentials are still valid).
411    pub fn config_hash(&self) -> String {
412        let mut serialized =
413            serde_json::to_value(self).expect("LeaseBackendConfig serialization should never fail");
414        // Strip non-security-relevant fields that shouldn't invalidate cache.
415        // With #[serde(tag = "type")] the JSON is flat: {"type":"aws-sts","duration":"1h",...}
416        if let Some(obj) = serialized.as_object_mut() {
417            obj.remove("duration");
418            obj.remove("timeout");
419        }
420        let json = serde_json::to_string(&serialized)
421            .expect("LeaseBackendConfig serialization should never fail");
422        let hash = blake3::hash(json.as_bytes());
423        hash.to_hex()[..16].to_string()
424    }
425
426    /// Get the configured duration string, if any
427    pub fn duration(&self) -> Option<&str> {
428        match self {
429            LeaseBackendConfig::AwsSts { duration, .. }
430            | LeaseBackendConfig::GcpIam { duration, .. }
431            | LeaseBackendConfig::Vault { duration, .. }
432            | LeaseBackendConfig::AzureToken { duration, .. }
433            | LeaseBackendConfig::Cloudflare { duration, .. }
434            | LeaseBackendConfig::GithubApp { duration, .. }
435            | LeaseBackendConfig::GithubOauth { duration, .. }
436            | LeaseBackendConfig::Command { duration, .. } => duration.as_deref(),
437        }
438    }
439}