Skip to main content

tryaudex_core/
credentials.rs

1use 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/// Temporary AWS credentials returned by STS.
11#[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    /// Returns env vars to inject into the subprocess.
21    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
30/// Issues scoped, short-lived AWS credentials via STS AssumeRole.
31pub 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    /// Assume a role with an inline policy that restricts permissions to the scoped policy.
52    /// Optionally attaches a permissions boundary ARN as an additional ceiling.
53    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    /// Assume a role with an inline policy and optional permissions boundary.
63    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    /// Assume a role with inline policy, optional boundary, and optional network conditions.
75    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    /// Assume a role with inline policy plus an optional tag-lock deny that
88    /// prevents the caller from modifying/removing a specific tag key. Used
89    /// by `--tag-session` so agents can't strip the tryaudex-session marker.
90    #[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        // AWS STS AssumeRole requires 900s <= DurationSeconds <= 43200s (15 minutes to 12 hours).
106        // Clamp at both ends rather than letting STS reject the call with a cryptic message.
107        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        // Attach user-supplied session tags to the STS call so they flow
140        // into CloudTrail and are available for attribute-based access control.
141        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        // Attach permissions boundary as a managed policy ARN ceiling
152        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            // AWS SDK's Display impl collapses ServiceError variants to "service error".
162            // Extract the actual error code + message so the user can act on it.
163            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
191/// Caches credentials on disk, keyed by session ID.
192pub 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    /// Save credentials for a session (encrypted at rest).
207    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    /// Load cached credentials for a session. Returns None if expired or missing.
213    /// Handles both encrypted and legacy plaintext files for migration.
214    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    /// Remove cached credentials for a session.
227    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}