stakpak-shared 0.3.67

Stakpak: Your DevOps AI Agent. Generate infrastructure code, debug Kubernetes, configure CI/CD, automate deployments, without giving an LLM the keys to production.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
//! Authentication manager for storing and retrieving provider credentials
//!
//! # Deprecated
//!
//! This module is **deprecated**. Credentials are now stored directly in `config.toml`
//! under `[profiles.{profile}.providers.{provider}.auth]` instead of in a separate
//! `auth.toml` file.
//!
//! The `AuthManager` is kept temporarily for:
//! - Reading existing `auth.toml` files during migration
//! - Backward compatibility during the transition period
//!
//! New code should use `ProviderConfig::set_auth()` and `ProviderConfig::get_auth()`
//! to manage provider credentials directly in `config.toml`.
//!
//! ## Migration
//!
//! When `config.toml` is loaded, any credentials in `auth.toml` are automatically
//! migrated to the new format in `config.toml`, and `auth.toml` is backed up to
//! `auth.toml.bak`.
//!
//! # Legacy File Structure (auth.toml - deprecated)
//!
//! ```toml
//! # Shared across all profiles
//! [all.anthropic]
//! type = "oauth"
//! access = "eyJ..."
//! refresh = "eyJ..."
//! expires = 1735600000000
//!
//! # Profile-specific override
//! [work.anthropic]
//! type = "api"
//! key = "sk-ant-..."
//! ```

use crate::models::auth::ProviderAuth;
use crate::oauth::error::{OAuthError, OAuthResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

/// The name of the auth configuration file
const AUTH_FILE_NAME: &str = "auth.toml";

/// Special profile name that provides defaults for all profiles
const ALL_PROFILE: &str = "all";

/// Structure of the auth.toml file
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AuthFile {
    /// Profile-scoped credentials: profile_name -> provider_name -> auth
    #[serde(flatten)]
    pub profiles: HashMap<String, HashMap<String, ProviderAuth>>,
}

/// Manages provider credentials stored in auth.toml
#[derive(Debug, Clone)]
pub struct AuthManager {
    /// Path to the auth.toml file
    auth_path: PathBuf,
    /// Loaded auth file contents
    auth_file: AuthFile,
}

impl AuthManager {
    /// Load auth manager for the given config directory
    pub fn new(config_dir: &Path) -> OAuthResult<Self> {
        let auth_path = config_dir.join(AUTH_FILE_NAME);
        let auth_file = if auth_path.is_file() {
            let content = std::fs::read_to_string(&auth_path)?;
            toml::from_str(&content)?
        } else {
            AuthFile::default()
        };

        Ok(Self {
            auth_path,
            auth_file,
        })
    }

    /// Load auth manager from the default Stakpak config directory (~/.stakpak/)
    pub fn from_default_dir() -> OAuthResult<Self> {
        let config_dir = get_default_config_dir()?;
        Self::new(&config_dir)
    }

    /// Get credentials for a provider, respecting profile inheritance
    ///
    /// Resolution order:
    /// 1. `[{profile}.{provider}]` - profile-specific
    /// 2. `[all.{provider}]` - shared fallback
    pub fn get(&self, profile: &str, provider: &str) -> Option<&ProviderAuth> {
        // First, check profile-specific credentials
        if let Some(providers) = self.auth_file.profiles.get(profile)
            && let Some(auth) = providers.get(provider)
        {
            return Some(auth);
        }

        // Fall back to "all" profile
        if profile != ALL_PROFILE
            && let Some(providers) = self.auth_file.profiles.get(ALL_PROFILE)
            && let Some(auth) = providers.get(provider)
        {
            return Some(auth);
        }

        None
    }

    /// Set credentials for a provider in a specific profile
    pub fn set(&mut self, profile: &str, provider: &str, auth: ProviderAuth) -> OAuthResult<()> {
        self.auth_file
            .profiles
            .entry(profile.to_string())
            .or_default()
            .insert(provider.to_string(), auth);

        self.save()
    }

    /// Remove credentials for a provider from a specific profile
    pub fn remove(&mut self, profile: &str, provider: &str) -> OAuthResult<bool> {
        let removed = if let Some(providers) = self.auth_file.profiles.get_mut(profile) {
            let removed = providers.remove(provider).is_some();
            // Clean up empty profile entries
            if providers.is_empty() {
                self.auth_file.profiles.remove(profile);
            }
            removed
        } else {
            false
        };

        if removed {
            self.save()?;
        }

        Ok(removed)
    }

    /// List all credentials
    pub fn list(&self) -> &HashMap<String, HashMap<String, ProviderAuth>> {
        &self.auth_file.profiles
    }

    /// Get all credentials for a specific profile (including inherited from "all")
    pub fn list_for_profile(&self, profile: &str) -> HashMap<String, &ProviderAuth> {
        let mut result = HashMap::new();

        // Start with "all" profile credentials
        if let Some(all_providers) = self.auth_file.profiles.get(ALL_PROFILE) {
            for (provider, auth) in all_providers {
                result.insert(provider.clone(), auth);
            }
        }

        // Override with profile-specific credentials
        if profile != ALL_PROFILE
            && let Some(profile_providers) = self.auth_file.profiles.get(profile)
        {
            for (provider, auth) in profile_providers {
                result.insert(provider.clone(), auth);
            }
        }

        result
    }

    /// Check if any credentials are configured
    pub fn has_credentials(&self) -> bool {
        self.auth_file
            .profiles
            .values()
            .any(|providers| !providers.is_empty())
    }

    /// Get the path to the auth file
    pub fn auth_path(&self) -> &Path {
        &self.auth_path
    }

    /// Update OAuth tokens for a provider (used during token refresh)
    pub fn update_oauth_tokens(
        &mut self,
        profile: &str,
        provider: &str,
        access: &str,
        refresh: &str,
        expires: i64,
    ) -> OAuthResult<()> {
        let auth = ProviderAuth::oauth(access, refresh, expires);
        self.set(profile, provider, auth)
    }

    /// Save changes to disk
    fn save(&self) -> OAuthResult<()> {
        // Ensure parent directory exists
        if let Some(parent) = self.auth_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let content = toml::to_string_pretty(&self.auth_file)?;

        // Write to a temp file first, then rename for atomicity
        let temp_path = self.auth_path.with_extension("toml.tmp");
        std::fs::write(&temp_path, &content)?;

        // Set file permissions to 0600 (owner read/write only) on Unix
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let permissions = std::fs::Permissions::from_mode(0o600);
            std::fs::set_permissions(&temp_path, permissions)?;
        }

        // Atomic rename
        std::fs::rename(&temp_path, &self.auth_path)?;

        Ok(())
    }
}

/// Get the default Stakpak config directory
pub fn get_default_config_dir() -> OAuthResult<PathBuf> {
    let home = dirs::home_dir().ok_or_else(|| {
        OAuthError::IoError(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "Could not determine home directory",
        ))
    })?;

    Ok(home.join(".stakpak"))
}

/// Get the auth file path for a given config directory
pub fn get_auth_file_path(config_dir: &Path) -> PathBuf {
    config_dir.join(AUTH_FILE_NAME)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn create_test_auth_manager() -> (AuthManager, TempDir) {
        let temp_dir = TempDir::new().unwrap();
        let manager = AuthManager::new(temp_dir.path()).unwrap();
        (manager, temp_dir)
    }

    #[test]
    fn test_new_empty() {
        let (manager, _temp) = create_test_auth_manager();
        assert!(!manager.has_credentials());
        assert!(manager.list().is_empty());
    }

    #[test]
    fn test_set_and_get() {
        let (mut manager, _temp) = create_test_auth_manager();

        let auth = ProviderAuth::api_key("sk-test-key");
        manager.set("default", "anthropic", auth.clone()).unwrap();

        let retrieved = manager.get("default", "anthropic");
        assert!(retrieved.is_some());
        assert_eq!(retrieved.unwrap(), &auth);
    }

    #[test]
    fn test_profile_inheritance() {
        let (mut manager, _temp) = create_test_auth_manager();

        // Set in "all" profile
        let all_auth = ProviderAuth::api_key("sk-all-key");
        manager.set("all", "anthropic", all_auth.clone()).unwrap();

        // Should be accessible from any profile
        assert_eq!(manager.get("default", "anthropic"), Some(&all_auth));
        assert_eq!(manager.get("work", "anthropic"), Some(&all_auth));
        assert_eq!(manager.get("all", "anthropic"), Some(&all_auth));
    }

    #[test]
    fn test_profile_override() {
        let (mut manager, _temp) = create_test_auth_manager();

        // Set in "all" profile
        let all_auth = ProviderAuth::api_key("sk-all-key");
        manager.set("all", "anthropic", all_auth.clone()).unwrap();

        // Override in "work" profile
        let work_auth = ProviderAuth::api_key("sk-work-key");
        manager.set("work", "anthropic", work_auth.clone()).unwrap();

        // "work" should get its own key
        assert_eq!(manager.get("work", "anthropic"), Some(&work_auth));

        // "default" should still get the "all" key
        assert_eq!(manager.get("default", "anthropic"), Some(&all_auth));
    }

    #[test]
    fn test_remove() {
        let (mut manager, _temp) = create_test_auth_manager();

        let auth = ProviderAuth::api_key("sk-test-key");
        manager.set("default", "anthropic", auth).unwrap();

        assert!(manager.get("default", "anthropic").is_some());

        let removed = manager.remove("default", "anthropic").unwrap();
        assert!(removed);

        assert!(manager.get("default", "anthropic").is_none());
    }

    #[test]
    fn test_remove_nonexistent() {
        let (mut manager, _temp) = create_test_auth_manager();

        let removed = manager.remove("default", "anthropic").unwrap();
        assert!(!removed);
    }

    #[test]
    fn test_list_for_profile() {
        let (mut manager, _temp) = create_test_auth_manager();

        let all_anthropic = ProviderAuth::api_key("sk-all-anthropic");
        let all_openai = ProviderAuth::api_key("sk-all-openai");
        let work_anthropic = ProviderAuth::api_key("sk-work-anthropic");

        manager
            .set("all", "anthropic", all_anthropic.clone())
            .unwrap();
        manager.set("all", "openai", all_openai.clone()).unwrap();
        manager
            .set("work", "anthropic", work_anthropic.clone())
            .unwrap();

        let work_creds = manager.list_for_profile("work");
        assert_eq!(work_creds.len(), 2);
        assert_eq!(work_creds.get("anthropic"), Some(&&work_anthropic));
        assert_eq!(work_creds.get("openai"), Some(&&all_openai));

        let default_creds = manager.list_for_profile("default");
        assert_eq!(default_creds.len(), 2);
        assert_eq!(default_creds.get("anthropic"), Some(&&all_anthropic));
        assert_eq!(default_creds.get("openai"), Some(&&all_openai));
    }

    #[test]
    fn test_persistence() {
        let temp_dir = TempDir::new().unwrap();

        // Create and save credentials
        {
            let mut manager = AuthManager::new(temp_dir.path()).unwrap();
            let auth = ProviderAuth::api_key("sk-test-key");
            manager.set("default", "anthropic", auth).unwrap();
        }

        // Load and verify
        {
            let manager = AuthManager::new(temp_dir.path()).unwrap();
            let retrieved = manager.get("default", "anthropic");
            assert!(retrieved.is_some());
            assert_eq!(retrieved.unwrap().api_key_value(), Some("sk-test-key"));
        }
    }

    #[test]
    fn test_oauth_tokens() {
        let (mut manager, _temp) = create_test_auth_manager();

        let expires = chrono::Utc::now().timestamp_millis() + 3600000;
        let auth = ProviderAuth::oauth("access-token", "refresh-token", expires);
        manager.set("default", "anthropic", auth).unwrap();

        let retrieved = manager.get("default", "anthropic").unwrap();
        assert!(retrieved.is_oauth());
        assert_eq!(retrieved.access_token(), Some("access-token"));
        assert_eq!(retrieved.refresh_token(), Some("refresh-token"));
    }

    #[test]
    fn test_update_oauth_tokens() {
        let (mut manager, _temp) = create_test_auth_manager();

        // Initial set
        manager
            .set(
                "default",
                "anthropic",
                ProviderAuth::oauth("old-access", "old-refresh", 0),
            )
            .unwrap();

        // Update tokens
        let new_expires = chrono::Utc::now().timestamp_millis() + 3600000;
        manager
            .update_oauth_tokens(
                "default",
                "anthropic",
                "new-access",
                "new-refresh",
                new_expires,
            )
            .unwrap();

        let retrieved = manager.get("default", "anthropic").unwrap();
        assert_eq!(retrieved.access_token(), Some("new-access"));
        assert_eq!(retrieved.refresh_token(), Some("new-refresh"));
    }

    #[cfg(unix)]
    #[test]
    fn test_file_permissions() {
        use std::os::unix::fs::PermissionsExt;

        let temp_dir = TempDir::new().unwrap();
        let mut manager = AuthManager::new(temp_dir.path()).unwrap();

        let auth = ProviderAuth::api_key("sk-test-key");
        manager.set("default", "anthropic", auth).unwrap();

        let metadata = std::fs::metadata(manager.auth_path()).unwrap();
        let mode = metadata.permissions().mode();

        // Check that file is readable/writable only by owner (0600)
        assert_eq!(mode & 0o777, 0o600);
    }
}