OXYGENHUB

An organisation's storage policy 8ddebaa7e856

Organisation → Settings → Storage: require every new repository to live
in a bucket, and list the store URL prefixes allowed (the organisation's
own buckets, so its data stays where they are). The New repository form
refuses anything else for that organisation and says what is allowed.
Migration 0045 (org_storage_policy); audited as org.storage_policy.
refs #96
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 01:44 UTC
Signature
✔ signed by Andy McCall
Hash
8ddebaa7e8560641d6344d79fec314794c37da187bb783dde1770dd8a5f333f1
Depends on
19bb31ff1f5c 1f78655a0466 2a83f5303458 4ac01a732552 64caed18236a 749d9a5fcd9f 7974adb0be66 93be9ce4653e b5668108487f d23464c0ac60
Depended on by
1062a0f8e985 76f93eaefe45 3b1bc27fe805 5d90e0e045e2 d7d182467382 7b26e6118bc0 8475176972e8
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
14 atoms, 1 tree operation, 9 files

Browse the tree at this change

added crates/hub/migrations/0045_org_storage_policy.sql +8 −0
1 +
-- An organisation's storage policy (oxygenhub#96): whether every new
2 +
-- repository must live in a bucket, and the store URL prefixes allowed.
3 +
CREATE TABLE org_storage_policy (
4 +
    org_id         INTEGER PRIMARY KEY REFERENCES organisations(id) ON DELETE CASCADE,
5 +
    require_bucket INTEGER NOT NULL DEFAULT 0,
6 +
    -- One store URL prefix per line; empty allows any bucket.
7 +
    allowed        TEXT NOT NULL DEFAULT ''
8 +
);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **An organisation's storage policy (#96).** An organisation's Settings › Storage can require every new repository to live in a bucket and list the buckets allowed, so its data stays where its own buckets are. The New repository form refuses anything else and says what is allowed.
8 +
7 9
- **A repository can live in its owner's bucket (#96).** The New repository form has a Storage section: this server's disk, or your own Amazon S3, Google Cloud Storage or Azure bucket, with credentials for it. The bucket is checked before anything is made. The credentials are sealed on the server and used only for that repository. The hub keeps a cache in front. The header shows a *bucket* tag, and Settings › Storage checks the bucket again and replaces the credentials. Tested with a push into a real S3 bucket.
8 10
9 11
- **Runners decline, and the queue says why a job waits (oxygenflow#36, OxygenFlow spec §11.4–11.5).** A runner can hand back a job it cannot run with a reason. The job is queued again and not offered to that runner while what it offers stays the same, for ten minutes at most. After three different runners decline it, the job ends as unplaceable with their reasons. Every 15 seconds the hub works out why each ready job no runner can take is waiting, from each runner's last claim: no runner online, a missing tier, the wrong architecture, an image it does not hold, a label, memory or CPUs, a scope, or a decline. The run page and the API show the reason, and a runner's page shows its tiers and what it declined.
…
121 unchanged lines
modified crates/hub/src/storage.rs +97 −0
…
246 unchanged lines
247 247
        }
248 248
    }
249 249
}
250 +
251 +
/// An organisation's storage policy: every new repository in a bucket,
252 +
/// and only in stores under these URL prefixes when there are any. The
253 +
/// buckets an organisation lists are its own, so where they are (the
254 +
/// region, the provider, the country) is where its data is.
255 +
#[derive(Clone, Debug, Default, PartialEq, Eq)]
256 +
pub struct Policy {
257 +
    pub require_bucket: bool,
258 +
    pub allowed: Vec<String>,
259 +
}
260 +
261 +
impl Policy {
262 +
    /// Whether a new repository may be kept at `store` (`None`: this
263 +
    /// hub's disk); otherwise what to tell its creator.
264 +
    pub fn permits(&self, org: &str, store: Option<&str>) -> Result<(), String> {
265 +
        match store {
266 +
            None if self.require_bucket => Err(format!(
267 +
                "{org} keeps every new repository in a bucket: choose “my own bucket” under Storage{}",
268 +
                if self.allowed.is_empty() { String::new() } else { format!(" ({})", self.allowed.join(", ")) }
269 +
            )),
270 +
            Some(url) if !self.allowed.is_empty() && !self.allowed.iter().any(|p| url.trim().starts_with(p.as_str())) => {
271 +
                Err(format!("{org} allows only these stores: {}", self.allowed.join(", ")))
272 +
            }
273 +
            _ => Ok(()),
274 +
        }
275 +
    }
276 +
}
277 +
278 +
pub async fn policy(pool: &Pool, org_id: i64) -> sqlx::Result<Policy> {
279 +
    let row: Option<(i64, String)> =
280 +
        sqlx::query_as("SELECT require_bucket, allowed FROM org_storage_policy WHERE org_id = ?").bind(org_id).fetch_optional(pool).await?;
281 +
    Ok(row
282 +
        .map(|(r, a)| Policy {
283 +
            require_bucket: r != 0,
284 +
            allowed: a.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect(),
285 +
        })
286 +
        .unwrap_or_default())
287 +
}
288 +
289 +
/// Set a policy from what a form gave: the allowed prefixes one per line
290 +
/// (or comma-separated), each a store URL of a bucket.
291 +
pub async fn set_policy(pool: &Pool, org_id: i64, require_bucket: bool, allowed: &str) -> Result<Policy, String> {
292 +
    let mut prefixes = Vec::new();
293 +
    for p in allowed.split([',', '\n']).map(str::trim).filter(|p| !p.is_empty()) {
294 +
        if p.contains('?') {
295 +
            return Err(format!("{p}: a prefix names a bucket and a path, with no settings"));
296 +
        }
297 +
        check_url(p).map_err(|e| format!("{p}: {e}"))?;
298 +
        prefixes.push(p.to_string());
299 +
    }
300 +
    sqlx::query(
301 +
        "INSERT INTO org_storage_policy (org_id, require_bucket, allowed) VALUES (?, ?, ?) \
302 +
         ON CONFLICT (org_id) DO UPDATE SET require_bucket = excluded.require_bucket, allowed = excluded.allowed",
303 +
    )
304 +
    .bind(org_id)
305 +
    .bind(require_bucket as i64)
306 +
    .bind(prefixes.join("\n"))
307 +
    .execute(pool)
308 +
    .await
309 +
    .map_err(|e| e.to_string())?;
310 +
    Ok(Policy { require_bucket, allowed: prefixes })
311 +
}
312 +
313 +
#[cfg(test)]
314 +
mod tests {
315 +
    use super::*;
316 +
317 +
    #[test]
318 +
    fn store_urls_a_hub_accepts() {
319 +
        assert!(check_url("s3://bucket/repos/x?region=eu-west-2").is_ok());
320 +
        assert!(check_url("gs://bucket/x").is_ok());
321 +
        assert!(check_url("az://account/container/x").is_ok());
322 +
        for (bad, why) in [
323 +
            ("s3://b/x?profile=prod", "?profile="),
324 +
            ("s3://b/x?region=a&endpoint=http://10.0.0.1", "?endpoint="),
325 +
            ("gs://b/x?credentials=/etc/key.json", "?credentials="),
326 +
            ("dir:///srv", "a store is"),
327 +
            ("file:///srv", "a store is"),
328 +
            ("az://account", "the container"),
329 +
        ] {
330 +
            let e = check_url(bad).unwrap_err();
331 +
            assert!(e.contains(why), "{bad}: {e}");
332 +
        }
333 +
    }
334 +
335 +
    #[test]
336 +
    fn a_policy_says_what_it_allows() {
337 +
        let open = Policy::default();
338 +
        assert!(open.permits("acme", None).is_ok());
339 +
        let strict = Policy { require_bucket: true, allowed: vec!["s3://acme-london/".into(), "gs://acme-eu/".into()] };
340 +
        let e = strict.permits("acme", None).unwrap_err();
341 +
        assert!(e.contains("acme keeps every new repository in a bucket") && e.contains("s3://acme-london/"), "{e}");
342 +
        assert!(strict.permits("acme", Some("s3://acme-london/repos/x?region=eu-west-2")).is_ok());
343 +
        let e = strict.permits("acme", Some("s3://acme-virginia/x")).unwrap_err();
344 +
        assert!(e.contains("allows only these stores"), "{e}");
345 +
    }
346 +
}
modified crates/hub/src/web/mod.rs +1 −0
…
93 unchanged lines
94 94
        .route("/orgs/{name}/settings/assistants/models", post(orgs::assistant_models))
95 95
        .route("/orgs/{name}/settings/variables", post(orgs::variables))
96 96
        .route("/orgs/{name}/settings/secrets", post(orgs::secrets))
97 +
        .route("/orgs/{name}/settings/storage", post(orgs::storage))
97 98
        .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token))
98 99
        .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove))
99 100
        .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries))
…
607 unchanged lines
modified crates/hub/src/web/orgs.rs +40 −0
…
75 unchanged lines
76 76
    variables: super::variables::Panel,
77 77
    /// The organisation's Flow secrets (#107).
78 78
    secrets: super::secrets::Panel,
79 +
    /// Where its new repositories may live (#96).
80 +
    policy: crate::storage::Policy,
79 81
}
80 82
81 83
async fn org_page(
…
24 unchanged lines
106 108
    let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
107 109
    let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
108 110
    let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?;
111 +
    let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
109 112
    Ok(Page(OrgSettingsPage {
113 +
        policy,
110 114
        shell,
111 115
        org,
112 116
        members,
…
290 unchanged lines
403 407
    }
404 408
}
405 409
410 +
#[derive(Deserialize)]
411 +
pub struct PolicyForm {
412 +
    #[serde(rename = "_csrf")]
413 +
    csrf_token: String,
414 +
    #[serde(default)]
415 +
    require_bucket: Option<String>,
416 +
    #[serde(default)]
417 +
    allowed: String,
418 +
}
419 +
420 +
/// `POST /orgs/{name}/settings/storage`: where the organisation's new
421 +
/// repositories may live (#96).
422 +
pub async fn storage(
423 +
    State(app): State<Shared>,
424 +
    viewer: Viewer,
425 +
    Path(name): Path<String>,
426 +
    Form(f): Form<PolicyForm>,
427 +
) -> Result<Page<OrgSettingsPage>, Error> {
428 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
429 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
430 +
    csrf(&viewer, &f.csrf_token, &shell)?;
431 +
    match crate::storage::set_policy(&app.db, org.id, f.require_bucket.is_some(), &f.allowed).await {
432 +
        Ok(p) => {
433 +
            let what = match (p.require_bucket, p.allowed.is_empty()) {
434 +
                (true, true) => "every new repository in a bucket".to_string(),
435 +
                (true, false) => format!("every new repository in {}", p.allowed.join(", ")),
436 +
                (false, true) => "any storage".to_string(),
437 +
                (false, false) => format!("buckets only in {}", p.allowed.join(", ")),
438 +
            };
439 +
            accounts::audit(&app.db, Some(&me), "org.storage_policy", &org.name, &what).await.ok();
440 +
            org_page(&app, shell, org, Some(format!("Storage policy saved: {what}.")), None).await
441 +
        }
442 +
        Err(e) => org_page(&app, shell, org, None, Some(format!("Storage policy: {e}."))).await,
443 +
    }
444 +
}
445 +
406 446
/// `POST /orgs/{name}/settings/variables`: the organisation's Flow
407 447
/// variables (#102).
408 448
pub async fn variables(
…
42 unchanged lines
modified crates/hub/src/web/repo_settings.rs +8 −0
…
110 unchanged lines
111 111
    {
112 112
        return Ok(fail("That repository exists."));
113 113
    }
114 +
    // An organisation's storage policy (oxygenhub#96).
115 +
    if let Some(org) = accounts::org_by_name(&app.db, &name.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? {
116 +
        let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
117 +
        let store = (f.storage == "bucket").then_some(f.bucket.store_url.as_str());
118 +
        if let Err(e) = policy.permits(&org.name, store) {
119 +
            return Ok(fail(&format!("{e}.")));
120 +
        }
121 +
    }
114 122
    if f.storage == "bucket" {
115 123
        // Kept in the owner's bucket (oxygenhub#96): checked before
116 124
        // anything is made, and nothing is left behind when it fails.
…
736 unchanged lines
modified crates/hub/tests/storage.rs +40 −0
…
218 unchanged lines
219 219
    use futures::FutureExt;
220 220
    f.catch_unwind().await
221 221
}
222 +
223 +
#[tokio::test(flavor = "multi_thread")]
224 +
async fn an_organisation_can_require_its_own_buckets() {
225 +
    let h = hub().await;
226 +
    let me = andy(&h.app).await;
227 +
    let csrf = csrf_of(&send(&h.app, "GET", "/orgs/new", Some(&me), None).await.1);
228 +
    let (st, body, _) = send(&h.app, "POST", "/orgs/new", Some(&me), Some(&format!("_csrf={csrf}&name=acme&display_name=Acme"))).await;
229 +
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
230 +
    let settings = send(&h.app, "GET", "/orgs/acme/settings", Some(&me), None).await.1;
231 +
    assert!(settings.contains("id=\"storage\""));
232 +
    let csrf = csrf_of(&settings);
233 +
    let set = |allowed: &str| {
234 +
        let form = format!("_csrf={csrf}&require_bucket=1&allowed={}", enc(allowed));
235 +
        let app = h.app.clone();
236 +
        let me = me.clone();
237 +
        async move { send(&app, "POST", "/orgs/acme/settings/storage", Some(&me), Some(&form)).await.1 }
238 +
    };
239 +
    let page = set("s3://acme-london/repos/?region=eu-west-2").await;
240 +
    assert!(page.contains("a prefix names a bucket and a path, with no settings"), "{page}");
241 +
    let page = set("s3://acme-london/repos/\ngs://acme-eu/").await;
242 +
    assert!(page.contains("Storage policy saved: every new repository in s3://acme-london/repos/, gs://acme-eu/."), "{page}");
243 +
244 +
    let new = |fields: String| {
245 +
        let app = h.app.clone();
246 +
        let me = me.clone();
247 +
        async move {
248 +
            let csrf = csrf_of(&send(&app, "GET", "/new", Some(&me), None).await.1);
249 +
            send(&app, "POST", "/new", Some(&me), Some(&format!("_csrf={csrf}&owner=acme&{fields}"))).await.1
250 +
        }
251 +
    };
252 +
    let page = new("name=ondisk".into()).await;
253 +
    assert!(page.contains("acme keeps every new repository in a bucket"), "{page}");
254 +
    let page = new(format!("name=elsewhere&storage=bucket&store_url={}&access_key_id=a&secret_access_key=b", enc("s3://other/x"))).await;
255 +
    assert!(page.contains("acme allows only these stores: s3://acme-london/repos/, gs://acme-eu/"), "{page}");
256 +
    assert!(db::repos::get(&h.app.db, "acme", "ondisk").await.unwrap().is_none());
257 +
    assert!(db::repos::get(&h.app.db, "acme", "elsewhere").await.unwrap().is_none());
258 +
    // A user's own repositories are not the organisation's business.
259 +
    let (st, body) = new_repo(&h.app, &me, "name=mine").await;
260 +
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
261 +
}
modified docs/spec.md +2 −1
…
149 unchanged lines
150 150
- **The check:** before anything is made, the bucket or prefix must be empty and pass the store conformance check under a probe prefix. Nothing is created when either fails.
151 151
- **What the hub keeps:** a bare repository directory whose `storage.url` names the store, with a cache in front (oxygen#68), and a `repo_storage` row holding the URL and the credentials, sealed under `keys/secrets.key`. At start the hub hands every repository's credentials to the cloud stores by URL, so nothing from its host's environment or cloud profiles is used for a repository.
152 152
- **Pages:** the header shows a *bucket* tag; its title names the URL to the repository's admins only. Settings › Storage shows the store and the last check, checks it again, and replaces the credentials once new ones pass the check. Both are audited as `repo.storage`.
153 -
- **Not yet:** forking such a repository is refused (clone and push instead). Deleting one leaves its data in the bucket, which is the owner's. An organisation storage policy, the Sync status and runs on references moved outside the hub are still to come.
153 +
- **Organisation policy:** Settings › Storage on an organisation can require every new repository to live in a bucket, and can list the store URL prefixes allowed (its own buckets, so its data stays where they are). The New repository form refuses anything else for that organisation, saying what is allowed. Repositories that exist are not moved. Changes are audited as `org.storage_policy`.
154 +
- **Not yet:** forking such a repository is refused (clone and push instead). Deleting one leaves its data in the bucket, which is the owner's. The Sync status and runs on references moved outside the hub are still to come.
154 155
155 156
### 4.2 Forks
156 157
…
572 unchanged lines
modified templates/org_settings.html +11 −0
…
15 unchanged lines
16 16
  <a href="#secrets">Secrets</a>
17 17
  <a href="#assistants">AI assistants</a>
18 18
  <a href="#teams">Teams</a>
19 +
  <a href="#storage">Storage</a>
19 20
</nav>
20 21
<div class="sections">
21 22
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
…
31 unchanged lines
53 54
    <button type="submit">ADD OR CHANGE</button>
54 55
  </form>
55 56
</section>
57 +
<form class="card" id="storage" method="post" action="/orgs/{{ org.name }}/settings/storage">
58 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
59 +
  <h2>Storage</h2>
60 +
  <p class="small mute">Where the organisation's new repositories may live. Repositories that exist stay where they are.</p>
61 +
  <label class="check"><input type="checkbox" name="require_bucket" value="1"{% if policy.require_bucket %} checked{% endif %}> Keep every new repository in a bucket, not on this server's disk</label>
62 +
  <label for="allowed">Buckets allowed, one store URL prefix per line (empty: any bucket)</label>
63 +
  <textarea id="allowed" name="allowed" rows="3" placeholder="s3://acme-london/repos/&#10;gs://acme-eu/" spellcheck="false">{{ policy.allowed.join("\n") }}</textarea>
64 +
  <p class="small mute">Listing the organisation's own buckets keeps its data where those buckets are: the provider, the region, the country.</p>
65 +
  <button type="submit">SAVE</button>
66 +
</form>
56 67
<section class="panel" id="teams">
57 68
  <h2>Teams</h2>
58 69
  {% for (t, ms) in teams %}
…
29 unchanged lines