OXYGENHUB
◆ image-check oxygenhub / crates / hub / src / storage.rs
16.8 KiB · Rust · last changed The store as its provider's icon; a tidier Projects page 25 days ago source blame history raw
1
//! Repositories kept in their owner's bucket (hub spec §4, oxygenhub#96,
2
//! oxygen#68). The repository's objects and operation log live in an S3,
3
//! Google Cloud Storage or Azure bucket the owner chose; the hub keeps a
4
//! bare repository directory with a cache in front (`storage.url` in its
5
//! config) and the credentials, sealed under `keys/secrets.key`. Stores
6
//! are opened by URL, and the credentials are handed to them through
7
//! oxy-strata's lookup, so nothing from the hub host's own environment or
8
//! cloud profiles is ever used for a repository.
9
10
use crate::db::{now_ms, Pool};
11
use crate::Shared;
12
use oxy_strata::Credentials;
13
use std::collections::HashMap;
14
use std::sync::RwLock;
15
16
#[derive(Clone, Debug, sqlx::FromRow)]
17
pub struct Storage {
18
    pub repo_id: i64,
19
    pub url: String,
20
    /// Sealed; `App.secret_key` opens it.
21
    pub credentials: Vec<u8>,
22
    pub created_ms: i64,
23
    pub checked_ms: Option<i64>,
24
    pub check_result: String,
25
}
26
27
/// What the stores opened by URL get: each repository's credentials by
28
/// its store URL, and the URL by repository id for pages.
29
#[derive(Default)]
30
struct Known {
31
    credentials: HashMap<String, Credentials>,
32
    /// By the repository's directory, unique even between hubs in one process.
33
    urls: HashMap<std::path::PathBuf, String>,
34
}
35
36
static KNOWN: RwLock<Option<Known>> = RwLock::new(None);
37
38
fn lookup(url: &str) -> Option<Credentials> {
39
    KNOWN.read().ok()?.as_ref()?.credentials.get(url).cloned()
40
}
41
42
fn remember(dir: std::path::PathBuf, url: &str, credentials: Credentials) {
43
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
44
    let known = known.get_or_insert_with(Known::default);
45
    known.credentials.insert(url.to_string(), credentials);
46
    known.urls.insert(dir, url.to_string());
47
}
48
49
fn forget(dir: &std::path::Path) {
50
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
51
    if let Some(k) = known.as_mut() {
52
        if let Some(url) = k.urls.remove(dir) {
53
            k.credentials.remove(&url);
54
        }
55
    }
56
}
57
58
/// Where a repository's data lives, when it is not on this hub's disk.
59
/// What the repository header shows of a store (#123): which provider,
60
/// and, for the repository's admins, where in it.
61
#[derive(Clone, Debug, PartialEq)]
62
pub struct StoreBadge {
63
    /// `gcs`, `s3` or `azure`: picks the icon.
64
    pub kind: &'static str,
65
    pub provider: &'static str,
66
    /// The bucket, or for Azure the storage account and container.
67
    pub place: String,
68
    pub prefix: String,
69
    pub region: Option<String>,
70
    /// The URL as stored, query and all.
71
    pub url: String,
72
}
73
74
impl StoreBadge {
75
    /// The parts of a store URL; `None` for a scheme the hub does not
76
    /// keep repositories on.
77
    pub fn of(url: &str) -> Option<StoreBadge> {
78
        let (scheme, rest) = url.split_once("://")?;
79
        let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
80
        let region = query.split('&').find_map(|kv| kv.strip_prefix("region=")).filter(|r| !r.is_empty()).map(str::to_string);
81
        let mut parts = path.trim_matches('/').splitn(if scheme == "az" { 3 } else { 2 }, '/');
82
        let (kind, provider, place) = match scheme {
83
            "gs" => ("gcs", "Google Cloud Storage", parts.next()?.to_string()),
84
            "s3" => ("s3", "Amazon S3", parts.next()?.to_string()),
85
            "az" => ("azure", "Azure Blob Storage", format!("{} / {}", parts.next()?, parts.next()?)),
86
            _ => return None,
87
        };
88
        Some(StoreBadge { kind, provider, place, prefix: parts.next().unwrap_or("").to_string(), region, url: url.to_string() })
89
    }
90
}
91
92
pub fn location(app: &Shared, owner: &str, name: &str) -> Option<String> {
93
    let dir = app.repos.dir(&crate::repos::RepoName::new(owner, name)?);
94
    KNOWN.read().ok()?.as_ref()?.urls.get(&dir).cloned()
95
}
96
97
/// The directory of the repository with row `repo_id`.
98
async fn dir_of(app: &Shared, repo_id: i64) -> Option<std::path::PathBuf> {
99
    let row = crate::db::repos::get_by_id(&app.db, repo_id).await.ok()??;
100
    Some(app.repos.dir(&crate::repos::RepoName::new(&row.owner, &row.name)?))
101
}
102
103
/// Hand the cloud stores to oxy-core, and every repository's credentials
104
/// to them. At start.
105
pub async fn load(app: &Shared) -> sqlx::Result<()> {
106
    oxy_strata::register();
107
    oxy_strata::set_credentials_lookup(lookup);
108
    for s in all(&app.db).await? {
109
        match app.secret_key.open(&s.credentials).map(|j| Credentials::from_json(&j)) {
110
            Some(Ok(c)) => match dir_of(app, s.repo_id).await {
111
                Some(dir) => remember(dir, &s.url, c),
112
                None => tracing::warn!("storage: repository {} has a store but no row", s.repo_id),
113
            },
114
            _ => tracing::warn!("storage: the credentials of repository {} do not open under keys/secrets.key", s.repo_id),
115
        }
116
    }
117
    Ok(())
118
}
119
120
pub async fn all(pool: &Pool) -> sqlx::Result<Vec<Storage>> {
121
    sqlx::query_as("SELECT * FROM repo_storage").fetch_all(pool).await
122
}
123
124
pub async fn of(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Storage>> {
125
    sqlx::query_as("SELECT * FROM repo_storage WHERE repo_id = ?").bind(repo_id).fetch_optional(pool).await
126
}
127
128
/// Check a store URL a user gave: a bucket on S3, Google Cloud Storage or
129
/// Azure, with no setting but `region`. A profile, a key file or an
130
/// endpoint would make the hub use its own host's credentials or reach
131
/// addresses on its network, and `dir://` its own disk.
132
pub fn check_url(url: &str) -> Result<oxy_strata::StoreUrl, String> {
133
    let scheme = url.trim().split_once("://").map(|(s, _)| s).unwrap_or("");
134
    if !matches!(scheme, "s3" | "gs" | "az") {
135
        return Err("a store is s3://BUCKET/PREFIX, gs://BUCKET/PREFIX or az://ACCOUNT/CONTAINER/PREFIX".into());
136
    }
137
    let parsed = oxy_strata::StoreUrl::parse(url.trim()).map_err(|e| e.to_string())?;
138
    if parsed.scheme == "az" && parsed.path.is_empty() {
139
        return Err("an Azure store names the account and the container: az://ACCOUNT/CONTAINER/PREFIX".into());
140
    }
141
    if let Some(k) = parsed.params.keys().find(|k| k.as_str() != "region") {
142
        return Err(format!("?{k}= is not allowed here: the only setting a hub accepts is ?region="));
143
    }
144
    Ok(parsed)
145
}
146
147
/// The credentials a form gave for a store of `scheme`.
148
pub fn credentials_from(
149
    scheme: &str,
150
    access_key_id: &str,
151
    secret_access_key: &str,
152
    service_account_key: &str,
153
    tenant_id: &str,
154
    client_id: &str,
155
    client_secret: &str,
156
) -> Result<Credentials, String> {
157
    let json = match scheme {
158
        "s3" => serde_json::json!({ "kind": "aws", "access_key_id": access_key_id, "secret_access_key": secret_access_key }),
159
        "gs" => serde_json::json!({ "kind": "gcp", "service_account_key": service_account_key }),
160
        "az" => serde_json::json!({ "kind": "azure", "tenant_id": tenant_id, "client_id": client_id, "client_secret": client_secret }),
161
        _ => return Err("unknown kind of store".into()),
162
    };
163
    Credentials::from_json(&json.to_string()).map_err(|e| e.to_string())
164
}
165
166
/// Run the conformance check against a store under a probe prefix of its
167
/// own, which it leaves empty: what passed, or what failed. With
168
/// `must_be_empty` the store must hold nothing yet (a new repository's).
169
pub async fn probe(url: &str, credentials: &Credentials, must_be_empty: bool) -> Result<String, String> {
170
    let (url, credentials) = (url.to_string(), credentials.clone());
171
    tokio::task::spawn_blocking(move || {
172
        use oxy_core::strata::Store as _;
173
        let store = oxy_strata::CloudStore::open_with(&url, Some(&credentials)).map_err(|e| e.to_string())?;
174
        if must_be_empty {
175
            let found = store.list("").map_err(|e| e.to_string())?;
176
            if !found.is_empty() {
177
                return Err(format!(
178
                    "{} already holds {} object(s): a new repository needs an empty bucket or prefix",
179
                    store.describe(),
180
                    found.len()
181
                ));
182
            }
183
        }
184
        let mut nonce = [0u8; 6];
185
        rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut nonce);
186
        let prefix = format!("oxy-probe-{}", hex::encode(nonce));
187
        let passed = oxy_core::strata::conformance(&store, &prefix).map_err(|e| {
188
            for e in store.list(&prefix).unwrap_or_default() {
189
                let _ = store.delete(&e.key);
190
            }
191
            e.to_string()
192
        })?;
193
        Ok(format!("{} checks passed", passed.len()))
194
    })
195
    .await
196
    .map_err(|e| e.to_string())?
197
}
198
199
/// Create repository `name` (row `repo_id`, already in the database) in
200
/// the store at `url`: checked first, then the credentials sealed and
201
/// kept, then the bare repository made over the store.
202
pub async fn create(app: &Shared, name: &crate::repos::RepoName, repo_id: i64, url: &str, credentials: Credentials) -> Result<(), String> {
203
    let result = probe(url, &credentials, true).await?;
204
    let sealed = app.secret_key.seal(&credentials.to_json());
205
    let dir = app.repos.dir(name);
206
    remember(dir.clone(), url, credentials);
207
    let made = {
208
        let (app, name, url) = (app.clone(), name.clone(), url.to_string());
209
        tokio::task::spawn_blocking(move || app.repos.create_on(&name, &url).map(drop).map_err(|e| e.to_string()))
210
            .await
211
            .map_err(|e| e.to_string())
212
            .and_then(|r| r)
213
    };
214
    if let Err(e) = made {
215
        forget(&dir);
216
        return Err(format!("the repository could not be made in {url}: {e}"));
217
    }
218
    let now = now_ms();
219
    sqlx::query("INSERT INTO repo_storage (repo_id, url, credentials, created_ms, checked_ms, check_result) VALUES (?, ?, ?, ?, ?, ?)")
220
        .bind(repo_id)
221
        .bind(url)
222
        .bind(sealed)
223
        .bind(now)
224
        .bind(now)
225
        .bind(&result)
226
        .execute(&app.db)
227
        .await
228
        .map_err(|e| e.to_string())?;
229
    Ok(())
230
}
231
232
/// Check a repository's store again and record the result.
233
pub async fn check(app: &Shared, s: &Storage) -> Result<String, String> {
234
    let json = app.secret_key.open(&s.credentials).ok_or("the credentials do not open under this hub's key")?;
235
    let credentials = Credentials::from_json(&json).map_err(|e| e.to_string())?;
236
    let result = probe(&s.url, &credentials, false).await;
237
    let text = match &result {
238
        Ok(r) => r.clone(),
239
        Err(e) => format!("failed: {e}"),
240
    };
241
    sqlx::query("UPDATE repo_storage SET checked_ms = ?, check_result = ? WHERE repo_id = ?")
242
        .bind(now_ms())
243
        .bind(&text)
244
        .bind(s.repo_id)
245
        .execute(&app.db)
246
        .await
247
        .map_err(|e| e.to_string())?;
248
    result
249
}
250
251
/// Replace a repository's credentials (a rotated key), once they pass the
252
/// check against its store.
253
pub async fn set_credentials(app: &Shared, s: &Storage, credentials: Credentials) -> Result<String, String> {
254
    let result = probe(&s.url, &credentials, false).await?;
255
    sqlx::query("UPDATE repo_storage SET credentials = ?, checked_ms = ?, check_result = ? WHERE repo_id = ?")
256
        .bind(app.secret_key.seal(&credentials.to_json()))
257
        .bind(now_ms())
258
        .bind(&result)
259
        .bind(s.repo_id)
260
        .execute(&app.db)
261
        .await
262
        .map_err(|e| e.to_string())?;
263
    if let Some(dir) = dir_of(app, s.repo_id).await {
264
        remember(dir, &s.url, credentials);
265
    }
266
    Ok(result)
267
}
268
269
/// A repository is gone (its store row with it): forget its credentials.
270
pub fn removed(app: &Shared, name: &crate::repos::RepoName) {
271
    forget(&app.repos.dir(name));
272
}
273
274
/// A repository moved to another name (a transfer): its store goes with it.
275
pub fn renamed(app: &Shared, from: &crate::repos::RepoName, to: &crate::repos::RepoName) {
276
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
277
    if let Some(k) = known.as_mut() {
278
        if let Some(url) = k.urls.remove(&app.repos.dir(from)) {
279
            k.urls.insert(app.repos.dir(to), url);
280
        }
281
    }
282
}
283
284
/// An organisation's storage policy: every new repository in a bucket,
285
/// and only in stores under these URL prefixes when there are any. The
286
/// buckets an organisation lists are its own, so where they are (the
287
/// region, the provider, the country) is where its data is.
288
#[derive(Clone, Debug, Default, PartialEq, Eq)]
289
pub struct Policy {
290
    pub require_bucket: bool,
291
    pub allowed: Vec<String>,
292
}
293
294
impl Policy {
295
    /// Whether a new repository may be kept at `store` (`None`: this
296
    /// hub's disk); otherwise what to tell its creator.
297
    pub fn permits(&self, org: &str, store: Option<&str>) -> Result<(), String> {
298
        match store {
299
            None if self.require_bucket => Err(format!(
300
                "{org} keeps every new repository in a bucket: choose “my own bucket” under Storage{}",
301
                if self.allowed.is_empty() { String::new() } else { format!(" ({})", self.allowed.join(", ")) }
302
            )),
303
            Some(url) if !self.allowed.is_empty() && !self.allowed.iter().any(|p| url.trim().starts_with(p.as_str())) => {
304
                Err(format!("{org} allows only these stores: {}", self.allowed.join(", ")))
305
            }
306
            _ => Ok(()),
307
        }
308
    }
309
}
310
311
pub async fn policy(pool: &Pool, org_id: i64) -> sqlx::Result<Policy> {
312
    let row: Option<(i64, String)> =
313
        sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = ?").bind(org_id).fetch_optional(pool).await?;
314
    Ok(row
315
        .map(|(r, a)| Policy {
316
            require_bucket: r != 0,
317
            allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect(),
318
        })
319
        .unwrap_or_default())
320
}
321
322
/// Set a policy from what a form gave: the allowed prefixes one per line
323
/// (or comma-separated), each a store URL of a bucket.
324
pub async fn set_policy(pool: &Pool, org_id: i64, require_bucket: bool, allowed: &str) -> Result<Policy, String> {
325
    let mut prefixes = Vec::new();
326
    for p in allowed.split([',', '\n']).map(str::trim).filter(|p| !p.is_empty()) {
327
        if p.contains('?') {
328
            return Err(format!("{p}: a prefix names a bucket and a path, with no settings"));
329
        }
330
        check_url(p).map_err(|e| format!("{p}: {e}"))?;
331
        prefixes.push(p.to_string());
332
    }
333
    sqlx::query(
334
        "INSERT INTO org_storage_policy (org_id, require_bucket, allowed) VALUES (?, ?, ?) \
335
         ON CONFLICT (org_id) DO UPDATE SET require_bucket = excluded.require_bucket, allowed = excluded.allowed",
336
    )
337
    .bind(org_id)
338
    .bind(require_bucket as i64)
339
    .bind(prefixes.join("\n"))
340
    .execute(pool)
341
    .await
342
    .map_err(|e| e.to_string())?;
343
    Ok(Policy { require_bucket, allowed: prefixes })
344
}
345
346
#[cfg(test)]
347
mod tests {
348
    use super::*;
349
350
    #[test]
351
    fn store_urls_a_hub_accepts() {
352
        assert!(check_url("s3://bucket/repos/x?region=eu-west-2").is_ok());
353
        assert!(check_url("gs://bucket/x").is_ok());
354
        assert!(check_url("az://account/container/x").is_ok());
355
        for (bad, why) in [
356
            ("s3://b/x?profile=prod", "?profile="),
357
            ("s3://b/x?region=a&endpoint=http://10.0.0.1", "?endpoint="),
358
            ("gs://b/x?credentials=/etc/key.json", "?credentials="),
359
            ("dir:///srv", "a store is"),
360
            ("file:///srv", "a store is"),
361
            ("az://account", "the container"),
362
        ] {
363
            let e = check_url(bad).unwrap_err();
364
            assert!(e.contains(why), "{bad}: {e}");
365
        }
366
    }
367
368
    #[test]
369
    fn a_policy_says_what_it_allows() {
370
        let open = Policy::default();
371
        assert!(open.permits("acme", None).is_ok());
372
        let strict = Policy { require_bucket: true, allowed: vec!["s3://acme-london/".into(), "gs://acme-eu/".into()] };
373
        let e = strict.permits("acme", None).unwrap_err();
374
        assert!(e.contains("acme keeps every new repository in a bucket") && e.contains("s3://acme-london/"), "{e}");
375
        assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok());
376
        let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err();
377
        assert!(e.contains("allows only these stores"), "{e}");
378
    }
379
}
380
381
#[cfg(test)]
382
mod badge_tests {
383
    use super::StoreBadge;
384
385
    #[test]
386
    fn a_store_url_names_its_provider_place_prefix_and_region() {
387
        let g = StoreBadge::of("gs://doomsdayone-oxy-repos/repos/tetris").unwrap();
388
        assert_eq!(
389
            (g.kind, g.provider, g.place.as_str(), g.prefix.as_str(), g.region),
390
            ("gcs", "Google Cloud Storage", "doomsdayone-oxy-repos", "repos/tetris", None)
391
        );
392
        let s = StoreBadge::of("s3://my-bucket/repos/name?region=eu-west-2").unwrap();
393
        assert_eq!(
394
            (s.kind, s.place.as_str(), s.prefix.as_str(), s.region.as_deref()),
395
            ("s3", "my-bucket", "repos/name", Some("eu-west-2"))
396
        );
397
        let a = StoreBadge::of("az://account/container/repos/x").unwrap();
398
        assert_eq!((a.kind, a.place.as_str(), a.prefix.as_str()), ("azure", "account / container", "repos/x"));
399
        assert_eq!(StoreBadge::of("s3://bucket").unwrap().prefix, "");
400
        assert!(StoreBadge::of("dir:///tmp/x").is_none());
401
        assert!(StoreBadge::of("az://only-account").is_none());
402
    }
403
}