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#[derive(Debug, Clone)]
19pub struct Lease {
20 pub credentials: IndexMap<String, String>,
22 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
24 pub lease_id: String,
26}
27
28#[async_trait]
30pub trait LeaseBackend: Send + Sync {
31 async fn create_lease(&self, duration: Duration, label: &str) -> Result<Lease>;
33
34 async fn revoke_lease(
40 &self,
41 _lease_id: &str,
42 _credentials: Option<&IndexMap<String, String>>,
43 ) -> Result<()> {
44 Ok(())
46 }
47
48 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
100pub fn generate_lease_id(prefix: &str) -> String {
103 let suffix: u64 = rand::random();
104 format!("{prefix}-{suffix:016x}")
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
109#[serde(tag = "type", rename_all = "kebab-case")]
110pub enum LeaseBackendConfig {
111 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 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 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 #[serde(default = "default_vault_method")]
146 method: String,
147 },
148 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 {
158 #[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 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 #[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 GithubOauth {
190 client_id: String,
191 #[serde(default = "default_github_oauth_scope")]
193 scope: String,
194 #[serde(default = "default_github_env_var")]
195 env_var: String,
196 #[serde(default = "default_github_oauth_keyring_service")]
198 keyring_service: String,
199 #[serde(default = "default_true")]
201 keyring_cache: bool,
202 #[serde(default = "default_true")]
204 open_browser: bool,
205 #[serde(default = "default_github_oauth_auth_base")]
207 auth_base: String,
208 #[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 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 #[serde(default = "default_command_timeout")]
223 timeout: String,
224 },
225}
226
227impl LeaseBackendConfig {
228 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 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 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 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 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 pub fn config_hash(&self) -> String {
412 let mut serialized =
413 serde_json::to_value(self).expect("LeaseBackendConfig serialization should never fail");
414 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 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}