Skip to main content

greentic_distributor_client/
store_auth.rs

1use greentic_secrets_lib::{DevStore, SecretFormat, SecretsStore};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct StoreCredentials {
8    pub tenant: String,
9    pub username: String,
10    pub token: String,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct StoreAuth {
15    auth_path: PathBuf,
16    state_path: PathBuf,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20struct StoreAuthState {
21    logins: Vec<StoreCredentials>,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum StoreAuthError {
26    #[error("{0}")]
27    Message(String),
28    #[error("io error at `{path}`: {source}")]
29    Io {
30        path: String,
31        #[source]
32        source: std::io::Error,
33    },
34    #[error("json error: {0}")]
35    Json(#[from] serde_json::Error),
36    #[error("secret store error: {0}")]
37    SecretStore(String),
38}
39
40pub fn default_store_auth_path() -> PathBuf {
41    if let Ok(path) = std::env::var("GREENTIC_DIST_STORE_SECRETS_PATH") {
42        return PathBuf::from(path);
43    }
44    default_store_auth_dir().join("store-auth.json")
45}
46
47pub fn default_store_state_path() -> PathBuf {
48    default_store_auth_path()
49}
50
51impl Default for StoreAuth {
52    fn default() -> Self {
53        Self::new(default_store_auth_path(), default_store_state_path())
54    }
55}
56
57impl StoreAuth {
58    pub fn new(auth_path: impl Into<PathBuf>, state_path: impl Into<PathBuf>) -> Self {
59        Self {
60            auth_path: auth_path.into(),
61            state_path: state_path.into(),
62        }
63    }
64
65    pub fn from_env() -> Self {
66        Self::default()
67    }
68
69    pub fn auth_path(&self) -> &Path {
70        &self.auth_path
71    }
72
73    pub fn state_path(&self) -> &Path {
74        &self.state_path
75    }
76
77    pub async fn save_login(&self, tenant: &str, token: &str) -> Result<(), StoreAuthError> {
78        save_login(&self.auth_path, &self.state_path, tenant, token).await
79    }
80
81    pub async fn load_login(&self, tenant: &str) -> Result<StoreCredentials, StoreAuthError> {
82        load_login(&self.auth_path, &self.state_path, tenant).await
83    }
84}
85
86pub async fn save_login_default(tenant: &str, token: &str) -> Result<(), StoreAuthError> {
87    StoreAuth::default().save_login(tenant, token).await
88}
89
90pub async fn load_login_default(tenant: &str) -> Result<StoreCredentials, StoreAuthError> {
91    StoreAuth::default().load_login(tenant).await
92}
93
94fn default_store_auth_dir() -> PathBuf {
95    if let Some(config) = dirs_next::config_dir() {
96        return config.join("greentic").join("dist");
97    }
98    if let Ok(root) = std::env::var("GREENTIC_HOME") {
99        return PathBuf::from(root).join("config").join("dist");
100    }
101    PathBuf::from(".greentic").join("config").join("dist")
102}
103
104pub async fn save_login(
105    auth_path: &Path,
106    _state_path: &Path,
107    tenant: &str,
108    token: &str,
109) -> Result<(), StoreAuthError> {
110    let tenant = tenant.trim();
111    if tenant.is_empty() {
112        return Err(StoreAuthError::Message("tenant cannot be empty".into()));
113    }
114    if token.is_empty() {
115        return Err(StoreAuthError::Message("token cannot be empty".into()));
116    }
117
118    let credentials = StoreCredentials {
119        tenant: tenant.to_string(),
120        username: tenant.to_string(),
121        token: token.to_string(),
122    };
123    let mut state = load_state(auth_path)
124        .await?
125        .unwrap_or(StoreAuthState { logins: Vec::new() });
126    state.logins.retain(|login| login.tenant != tenant);
127    state.logins.push(credentials);
128    write_state(auth_path, &state).await?;
129    Ok(())
130}
131
132pub async fn load_login(
133    auth_path: &Path,
134    _state_path: &Path,
135    tenant: &str,
136) -> Result<StoreCredentials, StoreAuthError> {
137    let state = load_state(auth_path).await?.ok_or_else(|| {
138        StoreAuthError::Message(format!(
139            "no saved store login found at `{}`; run `greentic-dist auth login <tenant>` first",
140            auth_path.display()
141        ))
142    })?;
143    let active = state
144        .logins
145        .into_iter()
146        .find(|login| login.tenant == tenant)
147        .ok_or_else(|| {
148            StoreAuthError::Message(format!(
149                "tenant `{tenant}` has no saved credentials; run `greentic-dist auth login {tenant}` first"
150            ))
151        })?;
152    if active.username.trim().is_empty() || active.token.is_empty() {
153        return Err(StoreAuthError::Message(format!(
154            "stored credentials for tenant `{}` are incomplete",
155            active.tenant
156        )));
157    }
158    Ok(active)
159}
160
161async fn load_state(path: &Path) -> Result<Option<StoreAuthState>, StoreAuthError> {
162    let store = open_store(path)?;
163    let bytes = match store.get("secrets://prod/dist/_/store/auth_state").await {
164        Ok(bytes) => bytes,
165        Err(err) if err.to_string().contains("not found") => return Ok(None),
166        Err(err) => return Err(StoreAuthError::SecretStore(err.to_string())),
167    };
168    let state: StoreAuthState = serde_json::from_slice(&bytes)?;
169    Ok(Some(state))
170}
171
172async fn write_state(path: &Path, state: &StoreAuthState) -> Result<(), StoreAuthError> {
173    ensure_parent_dir(path)?;
174    let store = open_store(path)?;
175    let bytes = serde_json::to_vec(state)?;
176    store
177        .put(
178            "secrets://prod/dist/_/store/auth_state",
179            SecretFormat::Json,
180            &bytes,
181        )
182        .await
183        .map_err(|err| StoreAuthError::SecretStore(err.to_string()))?;
184    Ok(())
185}
186
187fn open_store(path: &Path) -> Result<DevStore, StoreAuthError> {
188    DevStore::with_path(path).map_err(|err| {
189        StoreAuthError::SecretStore(format!(
190            "failed to open secrets store `{}`: {err}",
191            path.display()
192        ))
193    })
194}
195
196fn ensure_parent_dir(path: &Path) -> Result<(), StoreAuthError> {
197    if let Some(parent) = path.parent() {
198        fs::create_dir_all(parent).map_err(|source| StoreAuthError::Io {
199            path: parent.display().to_string(),
200            source,
201        })?;
202    }
203    Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[tokio::test]
211    async fn round_trips_login_credentials() {
212        let temp = tempfile::tempdir().unwrap();
213        let auth_path = temp.path().join("store-auth.json");
214        let state_path = auth_path.clone();
215
216        save_login(&auth_path, &state_path, "tenant-a", "secret-token")
217            .await
218            .unwrap();
219
220        let loaded = load_login(&auth_path, &state_path, "tenant-a")
221            .await
222            .unwrap();
223        assert_eq!(loaded.tenant, "tenant-a");
224        assert_eq!(loaded.username, "tenant-a");
225        assert_eq!(loaded.token, "secret-token");
226    }
227
228    #[tokio::test]
229    async fn store_auth_wrapper_round_trips_login_credentials() {
230        let temp = tempfile::tempdir().unwrap();
231        let auth = StoreAuth::new(
232            temp.path().join("store-auth.json"),
233            temp.path().join("store-auth.json"),
234        );
235
236        auth.save_login("tenant-b", "other-secret").await.unwrap();
237
238        let loaded = auth.load_login("tenant-b").await.unwrap();
239        assert_eq!(loaded.tenant, "tenant-b");
240        assert_eq!(loaded.username, "tenant-b");
241        assert_eq!(loaded.token, "other-secret");
242    }
243}