OXYGENHUB
# de4467cb1eca oxygenhub / crates / hub / src / storage.rs
16.8 KiB · Rust · last changed Postgres, step 3: the hub builds and passes its tests on PostgreSQL 20 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 = $1").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(
220
        "INSERT INTO repo_storage (repo_id, url, credentials, created_ms, checked_ms, check_result) VALUES ($1, $2, $3, $4, $5, $6)",
221
    )
222
    .bind(repo_id)
223
    .bind(url)
224
    .bind(sealed)
225
    .bind(now)
226
    .bind(now)
227
    .bind(&result)
228
    .execute(&app.db)
229
    .await
230
    .map_err(|e| e.to_string())?;
231
    Ok(())
232
}
233
234
/// Check a repository's store again and record the result.
235
pub async fn check(app: &Shared, s: &Storage) -> Result<String, String> {
236
    let json = app.secret_key.open(&s.credentials).ok_or("the credentials do not open under this hub's key")?;
237
    let credentials = Credentials::from_json(&json).map_err(|e| e.to_string())?;
238
    let result = probe(&s.url, &credentials, false).await;
239
    let text = match &result {
240
        Ok(r) => r.clone(),
241
        Err(e) => format!("failed: {e}"),
242
    };
243
    sqlx::query("UPDATE repo_storage SET checked_ms = $1, check_result = $2 WHERE repo_id = $3")
244
        .bind(now_ms())
245
        .bind(&text)
246
        .bind(s.repo_id)
247
        .execute(&app.db)
248
        .await
249
        .map_err(|e| e.to_string())?;
250
    result
251
}
252
253
/// Replace a repository's credentials (a rotated key), once they pass the
254
/// check against its store.
255
pub async fn set_credentials(app: &Shared, s: &Storage, credentials: Credentials) -> Result<String, String> {
256
    let result = probe(&s.url, &credentials, false).await?;
257
    sqlx::query("UPDATE repo_storage SET credentials = $1, checked_ms = $2, check_result = $3 WHERE repo_id = $4")
258
        .bind(app.secret_key.seal(&credentials.to_json()))
259
        .bind(now_ms())
260
        .bind(&result)
261
        .bind(s.repo_id)
262
        .execute(&app.db)
263
        .await
264
        .map_err(|e| e.to_string())?;
265
    if let Some(dir) = dir_of(app, s.repo_id).await {
266
        remember(dir, &s.url, credentials);
267
    }
268
    Ok(result)
269
}
270
271
/// A repository is gone (its store row with it): forget its credentials.
272
pub fn removed(app: &Shared, name: &crate::repos::RepoName) {
273
    forget(&app.repos.dir(name));
274
}
275
276
/// A repository moved to another name (a transfer): its store goes with it.
277
pub fn renamed(app: &Shared, from: &crate::repos::RepoName, to: &crate::repos::RepoName) {
278
    let mut known = KNOWN.write().unwrap_or_else(|p| p.into_inner());
279
    if let Some(k) = known.as_mut() {
280
        if let Some(url) = k.urls.remove(&app.repos.dir(from)) {
281
            k.urls.insert(app.repos.dir(to), url);
282
        }
283
    }
284
}
285
286
/// An organisation's storage policy: every new repository in a bucket,
287
/// and only in stores under these URL prefixes when there are any. The
288
/// buckets an organisation lists are its own, so where they are (the
289
/// region, the provider, the country) is where its data is.
290
#[derive(Clone, Debug, Default, PartialEq, Eq)]
291
pub struct Policy {
292
    pub require_bucket: bool,
293
    pub allowed: Vec<String>,
294
}
295
296
impl Policy {
297
    /// Whether a new repository may be kept at `store` (`None`: this
298
    /// hub's disk); otherwise what to tell its creator.
299
    pub fn permits(&self, org: &str, store: Option<&str>) -> Result<(), String> {
300
        match store {
301
            None if self.require_bucket => Err(format!(
302
                "{org} keeps every new repository in a bucket: choose “my own bucket” under Storage{}",
303
                if self.allowed.is_empty() { String::new() } else { format!(" ({})", self.allowed.join(", ")) }
304
            )),
305
            Some(url) if !self.allowed.is_empty() && !self.allowed.iter().any(|p| url.trim().starts_with(p.as_str())) => {
306
                Err(format!("{org} allows only these stores: {}", self.allowed.join(", ")))
307
            }
308
            _ => Ok(()),
309
        }
310
    }
311
}
312
313
pub async fn policy(pool: &Pool, org_id: i64) -> sqlx::Result<Policy> {
314
    let row: Option<(bool, String)> = sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = $1")
315
        .bind(org_id)
316
        .fetch_optional(pool)
317
        .await?;
318
    Ok(row
319
        .map(|(r, a)| Policy { require_bucket: r, allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect() })
320
        .unwrap_or_default())
321
}
322
323
/// Set a policy from what a form gave: the allowed prefixes one per line
324
/// (or comma-separated), each a store URL of a bucket.
325
pub async fn set_policy(pool: &Pool, org_id: i64, require_bucket: bool, allowed: &str) -> Result<Policy, String> {
326
    let mut prefixes = Vec::new();
327
    for p in allowed.split([',', '\n']).map(str::trim).filter(|p| !p.is_empty()) {
328
        if p.contains('?') {
329
            return Err(format!("{p}: a prefix names a bucket and a path, with no settings"));
330
        }
331
        check_url(p).map_err(|e| format!("{p}: {e}"))?;
332
        prefixes.push(p.to_string());
333
    }
334
    sqlx::query(
335
        "INSERT INTO org_storage_policy (org_id, require_bucket, allowed) VALUES ($1, $2, $3) \
336
         ON CONFLICT (org_id) DO UPDATE SET require_bucket = excluded.require_bucket, allowed = excluded.allowed",
337
    )
338
    .bind(org_id)
339
    .bind(require_bucket)
340
    .bind(prefixes.join("\n"))
341
    .execute(pool)
342
    .await
343
    .map_err(|e| e.to_string())?;
344
    Ok(Policy { require_bucket, allowed: prefixes })
345
}
346
347
#[cfg(test)]
348
mod tests {
349
    use super::*;
350
351
    #[test]
352
    fn store_urls_a_hub_accepts() {
353
        assert!(check_url("s3://bucket/repos/x?region=eu-west-2").is_ok());
354
        assert!(check_url("gs://bucket/x").is_ok());
355
        assert!(check_url("az://account/container/x").is_ok());
356
        for (bad, why) in [
357
            ("s3://b/x?profile=prod", "?profile="),
358
            ("s3://b/x?region=a&endpoint=http://10.0.0.1", "?endpoint="),
359
            ("gs://b/x?credentials=/etc/key.json", "?credentials="),
360
            ("dir:///srv", "a store is"),
361
            ("file:///srv", "a store is"),
362
            ("az://account", "the container"),
363
        ] {
364
            let e = check_url(bad).unwrap_err();
365
            assert!(e.contains(why), "{bad}: {e}");
366
        }
367
    }
368
369
    #[test]
370
    fn a_policy_says_what_it_allows() {
371
        let open = Policy::default();
372
        assert!(open.permits("acme", None).is_ok());
373
        let strict = Policy { require_bucket: true, allowed: vec!["s3://acme-london/".into(), "gs://acme-eu/".into()] };
374
        let e = strict.permits("acme", None).unwrap_err();
375
        assert!(e.contains("acme keeps every new repository in a bucket") && e.contains("s3://acme-london/"), "{e}");
376
        assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok());
377
        let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err();
378
        assert!(e.contains("allows only these stores"), "{e}");
379
    }
380
}
381
382
#[cfg(test)]
383
mod badge_tests {
384
    use super::StoreBadge;
385
386
    #[test]
387
    fn a_store_url_names_its_provider_place_prefix_and_region() {
388
        let g = StoreBadge::of("gs://doomsdayone-oxy-repos/repos/tetris").unwrap();
389
        assert_eq!(
390
            (g.kind, g.provider, g.place.as_str(), g.prefix.as_str(), g.region),
391
            ("gcs", "Google Cloud Storage", "doomsdayone-oxy-repos", "repos/tetris", None)
392
        );
393
        let s = StoreBadge::of("s3://my-bucket/repos/name?region=eu-west-2").unwrap();
394
        assert_eq!(
395
            (s.kind, s.place.as_str(), s.prefix.as_str(), s.region.as_deref()),
396
            ("s3", "my-bucket", "repos/name", Some("eu-west-2"))
397
        );
398
        let a = StoreBadge::of("az://account/container/repos/x").unwrap();
399
        assert_eq!((a.kind, a.place.as_str(), a.prefix.as_str()), ("azure", "account / container", "repos/x"));
400
        assert_eq!(StoreBadge::of("s3://bucket").unwrap().prefix, "");
401
        assert!(StoreBadge::of("dir:///tmp/x").is_none());
402
        assert!(StoreBadge::of("az://only-account").is_none());
403
    }
404
}