tryaudex_core/
credentials.rs1use std::path::PathBuf;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{AvError, Result};
7use crate::policy::ScopedPolicy;
8use crate::session::Session;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct TempCredentials {
13 pub access_key_id: String,
14 pub secret_access_key: String,
15 pub session_token: String,
16 pub expires_at: chrono::DateTime<chrono::Utc>,
17}
18
19impl TempCredentials {
20 pub fn as_env_vars(&self) -> Vec<(&str, &str)> {
22 vec![
23 ("AWS_ACCESS_KEY_ID", &self.access_key_id),
24 ("AWS_SECRET_ACCESS_KEY", &self.secret_access_key),
25 ("AWS_SESSION_TOKEN", &self.session_token),
26 ]
27 }
28}
29
30pub struct CredentialIssuer {
32 sts_client: aws_sdk_sts::Client,
33}
34
35impl CredentialIssuer {
36 pub async fn new() -> Result<Self> {
37 Self::with_region(None).await
38 }
39
40 pub async fn with_region(region: Option<&str>) -> Result<Self> {
41 let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
42 if let Some(region) = region {
43 loader = loader.region(aws_config::Region::new(region.to_string()));
44 }
45 let config = loader.load().await;
46 Ok(Self {
47 sts_client: aws_sdk_sts::Client::new(&config),
48 })
49 }
50
51 pub async fn issue(
54 &self,
55 session: &Session,
56 policy: &ScopedPolicy,
57 ttl: Duration,
58 ) -> Result<TempCredentials> {
59 self.issue_with_boundary(session, policy, ttl, None).await
60 }
61
62 pub async fn issue_with_boundary(
64 &self,
65 session: &Session,
66 policy: &ScopedPolicy,
67 ttl: Duration,
68 permissions_boundary: Option<&str>,
69 ) -> Result<TempCredentials> {
70 self.issue_full(session, policy, ttl, permissions_boundary, None)
71 .await
72 }
73
74 pub async fn issue_full(
76 &self,
77 session: &Session,
78 policy: &ScopedPolicy,
79 ttl: Duration,
80 permissions_boundary: Option<&str>,
81 network: Option<&crate::policy::NetworkPolicy>,
82 ) -> Result<TempCredentials> {
83 self.issue_full_with_tag_lock(session, policy, ttl, permissions_boundary, network, None)
84 .await
85 }
86
87 #[allow(clippy::too_many_arguments)]
91 pub async fn issue_full_with_tag_lock(
92 &self,
93 session: &Session,
94 policy: &ScopedPolicy,
95 ttl: Duration,
96 permissions_boundary: Option<&str>,
97 network: Option<&crate::policy::NetworkPolicy>,
98 tag_lock_key: Option<&str>,
99 ) -> Result<TempCredentials> {
100 let policy_json = match tag_lock_key {
101 Some(key) if network.is_none() => policy.to_iam_policy_json_with_tag_lock(key)?,
102 Some(key) => policy.to_iam_policy_json_with_network_and_tag_lock(network, Some(key))?,
103 None => policy.to_iam_policy_json_with_network(network)?,
104 };
105 let requested = ttl.as_secs();
108 let ttl_secs = requested.clamp(900, 43200) as i32;
109 if requested < 900 {
110 tracing::warn!(
111 requested_secs = requested,
112 clamped_to_secs = ttl_secs,
113 "TTL below AWS STS minimum (900s / 15m); clamping up"
114 );
115 } else if requested > 43200 {
116 tracing::warn!(
117 requested_secs = requested,
118 clamped_to_secs = ttl_secs,
119 "TTL above AWS STS maximum (43200s / 12h); clamping down"
120 );
121 }
122
123 tracing::info!(
124 session_id = %session.id,
125 role_arn = %session.role_arn,
126 ttl_secs = ttl_secs,
127 permissions_boundary = ?permissions_boundary,
128 "Assuming role with scoped policy"
129 );
130
131 let mut request = self
132 .sts_client
133 .assume_role()
134 .role_arn(&session.role_arn)
135 .role_session_name(format!("av-{}", &session.id[..8]))
136 .policy(&policy_json)
137 .duration_seconds(ttl_secs);
138
139 for (key, value) in &session.tags {
142 request = request.tags(
143 aws_sdk_sts::types::Tag::builder()
144 .key(key)
145 .value(value)
146 .build()
147 .unwrap(),
148 );
149 }
150
151 if let Some(boundary_arn) = permissions_boundary {
153 request = request.policy_arns(
154 aws_sdk_sts::types::PolicyDescriptorType::builder()
155 .arn(boundary_arn)
156 .build(),
157 );
158 }
159
160 let result = request.send().await.map_err(|e| {
161 let detail = match e.as_service_error() {
164 Some(svc) => {
165 let code = svc.meta().code().unwrap_or("Unknown");
166 let message = svc.meta().message().unwrap_or("(no message)");
167 format!("{code}: {message}")
168 }
169 None => e.to_string(),
170 };
171 AvError::Sts(detail)
172 })?;
173
174 let creds = result
175 .credentials()
176 .ok_or_else(|| AvError::Sts("No credentials returned by STS".to_string()))?;
177
178 let exp = creds.expiration();
179 let expires_at = chrono::DateTime::from_timestamp(exp.secs(), exp.subsec_nanos())
180 .unwrap_or_else(chrono::Utc::now);
181
182 Ok(TempCredentials {
183 access_key_id: creds.access_key_id().to_string(),
184 secret_access_key: creds.secret_access_key().to_string(),
185 session_token: creds.session_token().to_string(),
186 expires_at,
187 })
188 }
189}
190
191pub struct CredentialCache {
193 dir: PathBuf,
194}
195
196impl CredentialCache {
197 pub fn new() -> Result<Self> {
198 let dir = dirs::data_local_dir()
199 .unwrap_or_else(|| PathBuf::from("."))
200 .join("audex")
201 .join("cred_cache");
202 std::fs::create_dir_all(&dir)?;
203 Ok(Self { dir })
204 }
205
206 pub fn save(&self, session_id: &str, creds: &TempCredentials) -> Result<()> {
208 let path = self.dir.join(format!("{}.json", session_id));
209 crate::keystore::encrypt_to_file(&path, creds)
210 }
211
212 pub fn load(&self, session_id: &str) -> Result<Option<TempCredentials>> {
215 let path = self.dir.join(format!("{}.json", session_id));
216 let creds: Option<TempCredentials> = crate::keystore::decrypt_from_file(&path)?;
217 match creds {
218 Some(c) if c.expires_at <= chrono::Utc::now() => {
219 let _ = std::fs::remove_file(&path);
220 Ok(None)
221 }
222 other => Ok(other),
223 }
224 }
225
226 pub fn remove(&self, session_id: &str) {
228 let path = self.dir.join(format!("{}.json", session_id));
229 let _ = std::fs::remove_file(path);
230 }
231}