OXYGENHUB

Cloud stores take credentials handed over 7026bd7dc5b8

oxy_strata::Credentials (an S3 access key, a Google service account key,
an Azure service principal), CloudStore::open_with, and
set_credentials_lookup for stores opened by URL. Given credentials are
the only ones used: nothing from the environment or the providers'
files. Kept as JSON for sealing; Debug never shows a secret. Checked by
conformance against the real S3, GCS and Azure test stores with the
credentials handed over. refs #68
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 01:17 UTC
Signature
✔ signed by Andy McCall
Hash
7026bd7dc5b89e7162a2d846476d1370c902d5b9067f3355470110e3cec205d5
Depends on
2cd40ea1c04c 8d80356371c5 8fd78eb52ad2 c07406bcc237 d069701a4997
Depended on by
2ddd471651cd 579afd107b9b c9369ad8b1b1
In bookmarks
bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
19 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Cloud stores take credentials handed over (#68).** `oxy_strata::Credentials` (an S3 access key, a Google service account key, an Azure service principal) can be given when a store is opened, and a program can register a lookup for the stores it opens by URL. With them, nothing from the environment or the providers' files is used, which lets a hub keep each repository's credentials itself. Their debug form never shows a secret.
6 +
5 7
- **A repository kept on a store (#68).** `oxy init --store URL` keeps a repository's objects and operation log on `dir://`, S3, GCS or Azure, behind a local cache of `storage.cache` bytes (2 GiB by default) that can be deleted at any time. Checked against the real services: a commit takes 0.5 to 0.9 seconds, and reopening with no cache about half a second.
6 8
7 9
- **A caching store, and the operation head on a store (#68).** `strata::CachingStore` puts a local directory in front of any store: reads are kept, writes go to the remote first, the cache stays under a size limit by removing what was used least recently, and references always come from the remote. A repository kept on a store other than this disk's directory now holds its operation head there, moved by compare-and-swap with merge and retry.
…
110 unchanged lines
modified crates/oxy-strata/src/aws.rs +33 −7
…
94 unchanged lines
95 95
    })
96 96
}
97 97
98 -
pub fn open(url: &StoreUrl) -> Result<AmazonS3> {
98 +
/// With `keys` given, only they are used: nothing from the environment
99 +
/// or a profile, and the region and endpoint come from the URL alone.
100 +
pub fn open(url: &StoreUrl, keys: Option<(&str, &str, Option<&str>)>) -> Result<AmazonS3> {
101 +
    if let Some((id, secret, token)) = keys {
102 +
        let mut b = AmazonS3Builder::new()
103 +
            .with_bucket_name(&url.host)
104 +
            .with_http_connector(crate::http::UreqConnector)
105 +
            .with_conditional_put(S3ConditionalPut::ETagMatch)
106 +
            .with_access_key_id(id)
107 +
            .with_secret_access_key(secret);
108 +
        if let Some(t) = token {
109 +
            b = b.with_token(t);
110 +
        }
111 +
        let endpoint = url.param("endpoint").map(str::to_string);
112 +
        return finish(b, url, endpoint, url.param("region").map(str::to_string));
113 +
    }
99 114
    let bucket = url.host.clone();
100 115
    let explicit_profile = url.param("profile").map(str::to_string);
101 116
    let profile = explicit_profile
…
61 unchanged lines
163 178
        .or_else(|| env("AWS_ENDPOINT_URL"))
164 179
        .or_else(|| env("AWS_ENDPOINT"))
165 180
        .or_else(|| section.and_then(|s| s.get("endpoint_url").cloned()));
181 +
    let region = url
182 +
        .param("region")
183 +
        .map(str::to_string)
184 +
        .or_else(|| env("AWS_REGION"))
185 +
        .or_else(|| env("AWS_DEFAULT_REGION"))
186 +
        .or_else(|| section.and_then(|s| s.get("region").cloned()));
187 +
    finish(b, url, endpoint, region)
188 +
}
189 +
190 +
/// The endpoint and region, then the store.
191 +
fn finish(
192 +
    mut b: AmazonS3Builder,
193 +
    url: &StoreUrl,
194 +
    endpoint: Option<String>,
195 +
    region: Option<String>,
196 +
) -> Result<AmazonS3> {
197 +
    let bucket = url.host.clone();
166 198
    if let Some(e) = &endpoint {
167 199
        b = b
168 200
            .with_endpoint(e.clone())
…
2 unchanged lines
171 203
        b = b.with_virtual_hosted_style_request(false);
172 204
    }
173 205
174 -
    let region = url
175 -
        .param("region")
176 -
        .map(str::to_string)
177 -
        .or_else(|| env("AWS_REGION"))
178 -
        .or_else(|| env("AWS_DEFAULT_REGION"))
179 -
        .or_else(|| section.and_then(|s| s.get("region").cloned()));
180 206
    let region = match region {
181 207
        Some(r) => r,
182 208
        None if endpoint.is_some() => "us-east-1".into(),
…
27 unchanged lines
modified crates/oxy-strata/src/azure.rs +22 −1
…
17 unchanged lines
18 18
}
19 19
20 20
/// The store and the prefix under the container.
21 -
pub fn open(url: &StoreUrl) -> Result<(MicrosoftAzure, String)> {
21 +
/// With `principal` (tenant, client id, client secret) given, only it
22 +
/// is used.
23 +
pub fn open(
24 +
    url: &StoreUrl,
25 +
    principal: Option<(&str, &str, &str)>,
26 +
) -> Result<(MicrosoftAzure, String)> {
22 27
    let (container, prefix) = match url.path.split_once('/') {
23 28
        Some((c, p)) => (c.to_string(), p.to_string()),
24 29
        None if !url.path.is_empty() => (url.path.clone(), String::new()),
…
4 unchanged lines
29 34
            )))
30 35
        }
31 36
    };
37 +
    if let Some((tenant, id, secret)) = principal {
38 +
        let mut b = MicrosoftAzureBuilder::new()
39 +
            .with_account(&url.host)
40 +
            .with_container_name(&container)
41 +
            .with_http_connector(crate::http::UreqConnector)
42 +
            .with_client_secret_authorization(id, secret, tenant);
43 +
        if let Some(endpoint) = url.param("endpoint") {
44 +
            b = b
45 +
                .with_endpoint(endpoint.to_string())
46 +
                .with_allow_http(endpoint.starts_with("http://"));
47 +
        }
48 +
        let store = b
49 +
            .build()
50 +
            .map_err(|e| Error::Backend(format!("{}: {e}", url.plain())))?;
51 +
        return Ok((store, prefix));
52 +
    }
32 53
    let mut b = MicrosoftAzureBuilder::from_env()
33 54
        .with_account(&url.host)
34 55
        .with_container_name(&container)
…
32 unchanged lines
modified crates/oxy-strata/src/gcp.rs +14 −1
…
27 unchanged lines
28 28
        .unwrap_or(false)
29 29
}
30 30
31 -
pub fn open(url: &StoreUrl) -> Result<GoogleCloudStorage> {
31 +
/// With `key` (a service account key's JSON) given, only it is used.
32 +
pub fn open(url: &StoreUrl, key: Option<&str>) -> Result<GoogleCloudStorage> {
33 +
    if let Some(key) = key {
34 +
        let mut b = GoogleCloudStorageBuilder::new()
35 +
            .with_bucket_name(&url.host)
36 +
            .with_http_connector(crate::http::UreqConnector)
37 +
            .with_service_account_key(key);
38 +
        if let Some(endpoint) = url.param("endpoint") {
39 +
            b = b.with_base_url(endpoint);
40 +
        }
41 +
        return b
42 +
            .build()
43 +
            .map_err(|e| Error::Backend(format!("{}: {e}", url.plain())));
44 +
    }
32 45
    let mut b = GoogleCloudStorageBuilder::from_env()
33 46
        .with_bucket_name(&url.host)
34 47
        .with_http_connector(crate::http::UreqConnector);
…
24 unchanged lines
modified crates/oxy-strata/src/lib.rs +216 −4
…
45 unchanged lines
46 46
    if !matches!(scheme, "s3" | "gs" | "az") {
47 47
        return None;
48 48
    }
49 -
    Some(CloudStore::open(url).map(|s| Box::new(s) as Box<dyn Store>))
49 +
    let lookup = *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner());
50 +
    let given = lookup.and_then(|f| f(url));
51 +
    Some(CloudStore::open_with(url, given.as_ref()).map(|s| Box::new(s) as Box<dyn Store>))
50 52
}
51 53
54 +
/// Credentials a program keeps itself and hands over, instead of the
55 +
/// provider's usual places: a hub keeps each repository's sealed. When a
56 +
/// store is opened with them, nothing from the environment or the
57 +
/// provider's files is used.
58 +
#[derive(Clone, PartialEq, Eq)]
59 +
pub enum Credentials {
60 +
    /// An S3 access key.
61 +
    Aws {
62 +
        access_key_id: String,
63 +
        secret_access_key: String,
64 +
        session_token: Option<String>,
65 +
    },
66 +
    /// A Google Cloud service account key, as the JSON Google issues.
67 +
    Gcp { service_account_key: String },
68 +
    /// An Azure service principal.
69 +
    Azure {
70 +
        tenant_id: String,
71 +
        client_id: String,
72 +
        client_secret: String,
73 +
    },
74 +
}
75 +
76 +
impl std::fmt::Debug for Credentials {
77 +
    /// Names the kind and the public part only: never the secret.
78 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 +
        match self {
80 +
            Credentials::Aws { access_key_id, .. } => {
81 +
                write!(f, "Aws({access_key_id}, secret hidden)")
82 +
            }
83 +
            Credentials::Gcp { .. } => write!(f, "Gcp(service account key hidden)"),
84 +
            Credentials::Azure { client_id, .. } => write!(f, "Azure({client_id}, secret hidden)"),
85 +
        }
86 +
    }
87 +
}
88 +
89 +
impl Credentials {
90 +
    /// The scheme of the stores these are for.
91 +
    pub fn scheme(&self) -> &'static str {
92 +
        match self {
93 +
            Credentials::Aws { .. } => "s3",
94 +
            Credentials::Gcp { .. } => "gs",
95 +
            Credentials::Azure { .. } => "az",
96 +
        }
97 +
    }
98 +
99 +
    /// As JSON, for keeping (sealed) and reading back with [`Credentials::from_json`].
100 +
    pub fn to_json(&self) -> String {
101 +
        let v = match self {
102 +
            Credentials::Aws {
103 +
                access_key_id,
104 +
                secret_access_key,
105 +
                session_token,
106 +
            } => serde_json::json!({
107 +
                "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key,
108 +
                "session_token": session_token,
109 +
            }),
110 +
            Credentials::Gcp {
111 +
                service_account_key,
112 +
            } => {
113 +
                serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key })
114 +
            }
115 +
            Credentials::Azure {
116 +
                tenant_id,
117 +
                client_id,
118 +
                client_secret,
119 +
            } => serde_json::json!({
120 +
                "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret,
121 +
            }),
122 +
        };
123 +
        v.to_string()
124 +
    }
125 +
126 +
    pub fn from_json(text: &str) -> Result<Credentials> {
127 +
        let v: serde_json::Value = serde_json::from_str(text)
128 +
            .map_err(|e| Error::Backend(format!("credentials are not JSON: {e}")))?;
129 +
        let field = |k: &str| -> Result<String> {
130 +
            v.get(k)
131 +
                .and_then(|x| x.as_str())
132 +
                .filter(|x| !x.trim().is_empty())
133 +
                .map(|x| x.trim().to_string())
134 +
                .ok_or_else(|| Error::Backend(format!("credentials need {k}")))
135 +
        };
136 +
        match v.get("kind").and_then(|k| k.as_str()) {
137 +
            Some("aws") => Ok(Credentials::Aws {
138 +
                access_key_id: field("access_key_id")?,
139 +
                secret_access_key: field("secret_access_key")?,
140 +
                session_token: field("session_token").ok(),
141 +
            }),
142 +
            Some("gcp") => {
143 +
                let key = field("service_account_key")?;
144 +
                let parsed: serde_json::Value = serde_json::from_str(&key).map_err(|_| {
145 +
                    Error::Backend("the service account key is not the JSON Google issues".into())
146 +
                })?;
147 +
                if parsed.get("type").and_then(|t| t.as_str()) != Some("service_account") {
148 +
                    return Err(Error::Backend(
149 +
                        "the key is not a service account key (its type is not service_account)"
150 +
                            .into(),
151 +
                    ));
152 +
                }
153 +
                Ok(Credentials::Gcp {
154 +
                    service_account_key: key,
155 +
                })
156 +
            }
157 +
            Some("azure") => Ok(Credentials::Azure {
158 +
                tenant_id: field("tenant_id")?,
159 +
                client_id: field("client_id")?,
160 +
                client_secret: field("client_secret")?,
161 +
            }),
162 +
            _ => Err(Error::Backend(
163 +
                "credentials need a kind: aws, gcp or azure".into(),
164 +
            )),
165 +
        }
166 +
    }
167 +
}
168 +
169 +
/// Finds the credentials a program keeps for a store URL.
170 +
pub type CredentialsLookup = fn(&str) -> Option<Credentials>;
171 +
172 +
static CREDENTIALS: Mutex<Option<CredentialsLookup>> = Mutex::new(None);
173 +
174 +
/// Have stores opened by URL (as a repository's `storage.url` is) use the
175 +
/// credentials `lookup` finds for it, where it finds any.
176 +
pub fn set_credentials_lookup(lookup: CredentialsLookup) {
177 +
    *CREDENTIALS.lock().unwrap_or_else(|p| p.into_inner()) = Some(lookup);
178 +
}
179 +
52 180
/// A parsed store URL: `SCHEME://HOST/PATH?PARAMS`.
53 181
#[derive(Clone, Debug, PartialEq, Eq)]
54 182
pub struct StoreUrl {
…
82 unchanged lines
137 265
    /// Open the store a URL names. Nothing is sent yet: credentials are
138 266
    /// resolved here, but the bucket is first spoken to on the first call.
139 267
    pub fn open(url: &str) -> Result<CloudStore> {
268 +
        Self::open_with(url, None)
269 +
    }
270 +
271 +
    /// [`CloudStore::open`] with credentials given, which must be for
272 +
    /// the URL's kind of store; then only they are used.
273 +
    pub fn open_with(url: &str, given: Option<&Credentials>) -> Result<CloudStore> {
140 274
        let parsed = StoreUrl::parse(url)?;
275 +
        if let Some(c) = given.filter(|c| c.scheme() != parsed.scheme) {
276 +
            return Err(Error::Backend(format!(
277 +
                "{}: the credentials given are for {}:// stores",
278 +
                parsed.plain(),
279 +
                c.scheme()
280 +
            )));
281 +
        }
282 +
        let aws_keys = match given {
283 +
            Some(Credentials::Aws {
284 +
                access_key_id,
285 +
                secret_access_key,
286 +
                session_token,
287 +
            }) => Some((
288 +
                access_key_id.as_str(),
289 +
                secret_access_key.as_str(),
290 +
                session_token.as_deref(),
291 +
            )),
292 +
            _ => None,
293 +
        };
294 +
        let gcp_key = match given {
295 +
            Some(Credentials::Gcp {
296 +
                service_account_key,
297 +
            }) => Some(service_account_key.as_str()),
298 +
            _ => None,
299 +
        };
300 +
        let principal = match given {
301 +
            Some(Credentials::Azure {
302 +
                tenant_id,
303 +
                client_id,
304 +
                client_secret,
305 +
            }) => Some((
306 +
                tenant_id.as_str(),
307 +
                client_id.as_str(),
308 +
                client_secret.as_str(),
309 +
            )),
310 +
            _ => None,
311 +
        };
141 312
        let runtime = tokio::runtime::Builder::new_current_thread()
142 313
            .enable_all()
143 314
            .build()
…
1 unchanged lines
145 316
        let (inner, prefix, hint): (Arc<dyn ObjectStore>, String, &'static str) =
146 317
            match parsed.scheme.as_str() {
147 318
                "s3" => (
148 -
                    Arc::new(aws::open(&parsed)?),
319 +
                    Arc::new(aws::open(&parsed, aws_keys)?),
149 320
                    parsed.path.clone(),
150 321
                    aws::HINT,
151 322
                ),
152 323
                "gs" => (
153 -
                    Arc::new(gcp::open(&parsed)?),
324 +
                    Arc::new(gcp::open(&parsed, gcp_key)?),
154 325
                    parsed.path.clone(),
155 326
                    gcp::HINT,
156 327
                ),
157 328
                "az" => {
158 -
                    let (store, prefix) = azure::open(&parsed)?;
329 +
                    let (store, prefix) = azure::open(&parsed, principal)?;
159 330
                    (Arc::new(store), prefix, azure::HINT)
160 331
                }
161 332
                other => {
…
241 unchanged lines
403 574
404 575
#[cfg(test)]
405 576
mod tests {
577 +
    #[test]
578 +
    fn credentials_round_trip_and_never_show_their_secret() {
579 +
        let aws = Credentials::Aws {
580 +
            access_key_id: "AKIDEXAMPLE".into(),
581 +
            secret_access_key: "wJalrXUtnFEMI".into(),
582 +
            session_token: None,
583 +
        };
584 +
        assert_eq!(Credentials::from_json(&aws.to_json()).unwrap(), aws);
585 +
        assert_eq!(format!("{aws:?}"), "Aws(AKIDEXAMPLE, secret hidden)");
586 +
        let az = Credentials::Azure {
587 +
            tenant_id: "t".into(),
588 +
            client_id: "c".into(),
589 +
            client_secret: "s3cr3t".into(),
590 +
        };
591 +
        assert_eq!(Credentials::from_json(&az.to_json()).unwrap(), az);
592 +
        assert!(!format!("{az:?}").contains("s3cr3t"));
593 +
        let key = r#"{"type":"service_account","client_email":"x@y","private_key":"k"}"#;
594 +
        let gcp = Credentials::Gcp {
595 +
            service_account_key: key.into(),
596 +
        };
597 +
        assert_eq!(Credentials::from_json(&gcp.to_json()).unwrap(), gcp);
598 +
        assert!(!format!("{gcp:?}").contains("private_key"));
599 +
        for (bad, why) in [
600 +
            (r#"{"kind":"aws","access_key_id":"a"}"#, "secret_access_key"),
601 +
            (
602 +
                r#"{"kind":"gcp","service_account_key":"{\"type\":\"authorized_user\"}"}"#,
603 +
                "not a service account key",
604 +
            ),
605 +
            (r#"{"kind":"ftp"}"#, "aws, gcp or azure"),
606 +
            ("nope", "not JSON"),
607 +
        ] {
608 +
            let e = Credentials::from_json(bad).unwrap_err().to_string();
609 +
            assert!(e.contains(why), "{bad}: {e}");
610 +
        }
611 +
        let e = CloudStore::open_with("gs://bucket/p", Some(&aws))
612 +
            .err()
613 +
            .unwrap()
614 +
            .to_string();
615 +
        assert!(e.contains("are for s3:// stores"), "{e}");
616 +
    }
617 +
406 618
    use super::*;
407 619
408 620
    #[test]
…
30 unchanged lines
modified crates/oxy-strata/tests/cloud.rs +72 −0
…
198 unchanged lines
199 199
    }
200 200
}
201 201
202 +
/// Each store opened with credentials handed over (`CloudStore::open_with`,
203 +
/// as a hub does with the ones it keeps sealed), the profile and key file
204 +
/// parameters left off the URL, passes the conformance check.
205 +
#[test]
206 +
fn every_configured_store_conforms_with_credentials_handed_over() {
207 +
    use oxy_strata::Credentials;
208 +
    let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
209 +
    let mut tried = 0;
210 +
    if let (Some(url), Some(profile)) = (env("OXY_TEST_S3"), env("OXY_TEST_S3_PROFILE")) {
211 +
        let home = std::path::PathBuf::from(env("HOME").unwrap());
212 +
        let text = std::fs::read_to_string(home.join(".aws/credentials")).unwrap_or_default();
213 +
        let mut section = String::new();
214 +
        let mut keys = std::collections::BTreeMap::new();
215 +
        for line in text.lines().map(str::trim) {
216 +
            if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
217 +
                section = name.to_string();
218 +
            } else if let Some((k, v)) = line.split_once('=') {
219 +
                if section == profile {
220 +
                    keys.insert(k.trim().to_string(), v.trim().to_string());
221 +
                }
222 +
            }
223 +
        }
224 +
        if let (Some(id), Some(secret)) = (
225 +
            keys.get("aws_access_key_id"),
226 +
            keys.get("aws_secret_access_key"),
227 +
        ) {
228 +
            let c = Credentials::Aws {
229 +
                access_key_id: id.clone(),
230 +
                secret_access_key: secret.clone(),
231 +
                session_token: keys.get("aws_session_token").cloned(),
232 +
            };
233 +
            check_handed_over(&url, &c);
234 +
            tried += 1;
235 +
        }
236 +
    }
237 +
    if let (Some(url), Some(file)) = (env("OXY_TEST_GCS"), env("OXY_TEST_GCS_CREDENTIALS")) {
238 +
        let c = Credentials::Gcp {
239 +
            service_account_key: std::fs::read_to_string(file).unwrap(),
240 +
        };
241 +
        check_handed_over(&url, &c);
242 +
        tried += 1;
243 +
    }
244 +
    if let (Some(url), Some(file)) = (env("OXY_TEST_AZURE"), env("OXY_TEST_AZURE_CREDENTIALS")) {
245 +
        let mut vars = std::collections::BTreeMap::new();
246 +
        for line in std::fs::read_to_string(file).unwrap().lines() {
247 +
            if let Some((k, v)) = line.split_once('=') {
248 +
                vars.insert(k.trim().to_string(), v.trim().to_string());
249 +
            }
250 +
        }
251 +
        let c = Credentials::Azure {
252 +
            tenant_id: vars["AZURE_TENANT_ID"].clone(),
253 +
            client_id: vars["AZURE_CLIENT_ID"].clone(),
254 +
            client_secret: vars["AZURE_CLIENT_SECRET"].clone(),
255 +
        };
256 +
        check_handed_over(&url, &c);
257 +
        tried += 1;
258 +
    }
259 +
    eprintln!("{tried} stores checked with credentials handed over");
260 +
}
261 +
262 +
fn check_handed_over(url: &str, c: &oxy_strata::Credentials) {
263 +
    let store =
264 +
        oxy_strata::CloudStore::open_with(url, Some(c)).unwrap_or_else(|e| panic!("{url}: {e}"));
265 +
    let prefix = format!("probe-{}", std::process::id());
266 +
    let passed = conformance(&store, &prefix).unwrap_or_else(|e| {
267 +
        remove_all(&store, &prefix);
268 +
        panic!("{url} with {c:?}: {e}")
269 +
    });
270 +
    eprintln!("{url} with {c:?}: {} checks passed", passed.len());
271 +
    assert_eq!(passed.len(), 8);
272 +
}
273 +
202 274
fn write(root: &Path, rel: &str, content: &[u8]) {
203 275
    let p = root.join(rel);
204 276
    std::fs::create_dir_all(p.parent().unwrap()).unwrap();
…
2 unchanged lines