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/ 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 |