diff --git a/.env.example b/.env.example index b70d1117..f40a479d 100644 --- a/.env.example +++ b/.env.example @@ -94,9 +94,14 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false # Require the authenticated pusher to be the repo owner on git-receive-pack. # A valid did:key signature is authentication, not authorization: anyone can -# sign as their own DID. When true, pushes from a non-owner DID are rejected. -# Keep false until the repo owner is ready for owner-only writes. -GITLAWB_ENFORCE_OWNER_PUSH=false +# mint a key, derive its DID and sign, so with this off every signed caller may +# push to every repository, private ones included. On by default. +# +# Set to false only for a rolling upgrade whose pushers are not yet the repo +# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is +# verified but not yet honored for authorization, so they cannot push while this +# is on. See docs/RUN-A-NODE.md. +GITLAWB_ENFORCE_OWNER_PUSH=true # Comma-separated libp2p multiaddrs. # Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW... @@ -191,8 +196,9 @@ GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16 # many WRITE-pool slots. This one is load-bearing for push availability, since # it is acquired before the global write permit: without it, one host minting # disposable did:key identities could open enough slow pushes to monopolize the -# write pool and 503 every other source (owner enforcement defaults off, and -# the push rate limiter caps arrival rate, not in-flight concurrency). +# write pool and 503 every other source (the push rate limiter caps arrival +# rate, not in-flight concurrency, and owner enforcement can be turned off for +# a rolling upgrade). # Keyed on the resolved source IP, never the DID, so a DID farm does not defeat # them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above. diff --git a/README.md b/README.md index 1588161f..60055cee 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,8 @@ Important node settings: | `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. | | `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. | | `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. | -| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. | +| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. | +| `GITLAWB_ENFORCE_OWNER_PUSH` | Require the authenticated pusher to be the repo owner on `git-receive-pack`. **Defaults to `true`.** A `did:key` signature is authentication, not authorization — anyone can mint a key and sign — so with this off every signed caller may push to every repository, private ones included. Delegated and CI keys count as non-owners: a UCAN `git/push` capability is verified but not yet honored for authorization, so they cannot push while this is on. Set `false` only for a rolling upgrade; see [`docs/RUN-A-NODE.md`](docs/RUN-A-NODE.md). | | `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. | | `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. | | `GITLAWB_GIT_SERVICE_TIMEOUT_SECS` | Max seconds a served git upload-pack, receive-pack, or `info/refs` advertisement may run before it is aborted (504). Default 600. Also bounds the withheld-blob classification walk (on both the upload-pack serve and receive-pack replication paths) and the push-side pin-candidate discovery (`rev-list` / `cat-file`), each reaped via process-group teardown at the deadline. On the path-scoped upload-pack path the classification walk and the pack serve share ONE deadline, so this value bounds their combined duration rather than granting each stage a full budget: a walk that consumes it leaves the serve nothing and the clone gets a 504. Serving large path-scoped repos may therefore need a higher value than they did when each stage was budgeted separately. Accepted range is 1 to 3153600000 (100 years), since the node derives deadlines from this value and a larger one cannot be represented. | diff --git a/crates/gitlawb-node/src/api/repos.rs b/crates/gitlawb-node/src/api/repos.rs index b09cb6da..93f91dad 100644 --- a/crates/gitlawb-node/src/api/repos.rs +++ b/crates/gitlawb-node/src/api/repos.rs @@ -1780,7 +1780,7 @@ pub async fn git_receive_pack( "parsed ref updates from pack" ); - // ── Owner-only push enforcement (opt-in: GITLAWB_ENFORCE_OWNER_PUSH) ── + // ── Owner-only push enforcement (on by default; GITLAWB_ENFORCE_OWNER_PUSH) ── // Runs before branch protection on purpose: when enabled, a non-owner is // rejected here regardless of whether the target branch is protected, so a // single rejection never yields two different error bodies. The identity is @@ -1903,9 +1903,12 @@ pub async fn git_receive_pack( // (INV-10) and a saturated pool sheds 503 before spawning git. // // Per-source sub-cap first (#174 P1-d): one source IP cannot occupy the whole write - // pool via many slow pushes. Owner enforcement defaults off, so any valid did:key is - // accepted (auth != authz) and the push rate limiter bounds arrival RATE, not in-flight - // concurrency. Keyed on the resolved source IP, NEVER the signed DID (a DID farm defeats + // pool via many slow pushes. The push rate limiter bounds arrival RATE, not in-flight + // concurrency, so this cap is what bounds occupancy. Owner enforcement is on by + // default now, but it is not what makes this cap load-bearing: an operator may turn it + // off for a rolling upgrade, and even with it on a single owner can open many + // concurrent slow pushes to their own repo. + // Keyed on the resolved source IP, NEVER the signed DID (a DID farm defeats // a DID key); no resolvable key -> global write pool only. Then the global write permit: // pushes draw from the dedicated WRITE pool, separate from reads, and it is held for the // whole op (moved into the AdmissionGuard below). @@ -4993,6 +4996,13 @@ mod tests { /// task the global slot stays occupied until the walk finishes; on the pre-fix code /// the handler-local permits drop on future-drop and the slot frees instantly (RED), /// letting disconnect-spam exceed the cap while real git work keeps running. + /// + /// Unix-only: the fake git is a `/bin/sh` script made executable through + /// `PermissionsExt::set_mode`, and the hung `rev-list` is reaped with + /// `libc::kill(SIGKILL)`. Neither exists on Windows, so without this gate the + /// whole `gitlawb-node` test target fails to compile there (#228) and no test + /// in the crate can run on a Windows checkout. + #[cfg(unix)] #[sqlx::test] async fn upload_pack_permit_held_through_walk_after_disconnect(pool: sqlx::PgPool) { use axum::body::Body; @@ -5588,8 +5598,9 @@ mod tests { /// #174 U4 (P1-d, RED-before/GREEN-after): the authenticated receive-pack POST /// carries a per-source WRITE sub-cap so one source IP cannot monopolize the write - /// pool with many slow pushes (owner enforcement defaults off, so disposable DIDs - /// are free). Global write pool has capacity; the source is pre-held at its single + /// pool with many slow pushes (the cap keys on source IP, not the DID, so it holds + /// whether or not owner enforcement is on — and it can be turned off for a rolling + /// upgrade). Global write pool has capacity; the source is pre-held at its single /// write slot. A push from THAT source sheds (Overloaded/503) — which also proves /// the PeerAddr+HeaderMap extractors resolve a key (without them the key is None and /// the cap is inert, never shedding). A push from a DIFFERENT source is NOT shed by @@ -5615,7 +5626,10 @@ mod tests { .await .unwrap(); - let did = "did:key:z6MkReceivePackWriteCapProofDidAAAAAAAAAA"; + // Owner of the `rp4` row, so the push reaches the per-source write cap instead + // of stopping at the owner-push gate (on by default). `did_matches` collapses + // the `did:key:` prefix against the bare stored owner. + let did = "did:key:z6rp4wr"; let capped: SocketAddr = "203.0.113.44:5000".parse().unwrap(); let other: SocketAddr = "203.0.113.45:5000".parse().unwrap(); @@ -5734,7 +5748,10 @@ mod tests { .await .expect("hold the advisory lock on the second connection"); - let did = "did:key:z6MkAcquireDeadlineProofDidAAAAAAAAAAAAAAAA"; + // Owner of the seeded rows, so the push reaches `acquire_write` instead of + // stopping at the owner-push gate (on by default). `did_matches` collapses the + // `did:key:` prefix against the bare `owner` above. + let did = "did:key:z6acqdead"; let peer: SocketAddr = "203.0.113.61:5000".parse().unwrap(); let sem = state.git_write_semaphore.clone(); @@ -5860,6 +5877,11 @@ mod tests { /// sheds — releasing the permit lets the SAME walk run and pin (durability stays /// fail-closed). Exercises the gating seam directly; the detached push task calls /// this exact helper. + /// + /// Unix-only for the same reason as + /// `upload_pack_permit_held_through_walk_after_disconnect`: the fake git is a + /// `/bin/sh` script made executable through `PermissionsExt::set_mode` (#228). + #[cfg(unix)] #[tokio::test] async fn encrypt_walk_defers_when_pool_exhausted() { use std::sync::Arc; @@ -6360,15 +6382,17 @@ mod tests { // Scan pool of ONE: at most one post-receive walk may run at a time. state.git_encrypt_semaphore = Arc::new(Semaphore::new(1)); - let did = "did:key:z6MkF4BurstPusherAAAAAAAAAAAAAAAAAAAAAAAA"; let new_sha = "1111111111111111111111111111111111111111"; + // The two repos have different owners, and owner-only push is on by default, + // so each push signs as the owner of the repo it targets. The subject here is + // the scan pool serializing two concurrent bursts, not authorization. let push = |owner: &'static str, name: &'static str, peer: &'static str| { let state = state.clone(); tokio::spawn(async move { git_receive_pack( State(state), Path((owner.to_string(), name.to_string())), - Extension(crate::auth::AuthenticatedDid(did.to_string())), + Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some(peer.parse::().unwrap())), axum::http::HeaderMap::new(), ref_update_body(new_sha), @@ -6461,7 +6485,7 @@ mod tests { State(state), Path(("z6f4fast".to_string(), "f1".to_string())), Extension(crate::auth::AuthenticatedDid( - "did:key:z6MkF4FastPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "did:key:z6f4fast".to_string(), )), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), @@ -6521,7 +6545,7 @@ mod tests { State(state), Path(("z6f4park".to_string(), "p1".to_string())), Extension(crate::auth::AuthenticatedDid( - "did:key:z6MkF4ParkPusherAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "did:key:z6f4park".to_string(), )), crate::rate_limit::PeerAddr(Some(peer)), axum::http::HeaderMap::new(), @@ -7740,7 +7764,7 @@ mod tests { // + flush-only body -> no post-receive scans to muddy the observation. let state = f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f3repo", "r1", false).await; - let did = "did:key:z6MkF3PusherAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6f3repo"; // Push A: drive its handler future in slices until it reaches receive-pack and the // fake records its descendant pid (A now holds the lease and is hung). @@ -7863,7 +7887,7 @@ mod tests { let git_bin = write_fake_git(tmp.path(), body); let state = f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f3clean", "c1", false).await; - let did = "did:key:z6MkF3CleanPusherAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6f3clean"; let push = |st: AppState, peer: &'static str| async move { tokio::time::timeout( @@ -7957,7 +7981,7 @@ mod tests { // free, and a pool-holding waiter (B, pre-fix) would drain it to zero. Sizing to // 1 would 503 B on the pool before it could block on the lease, hiding the bug. state.git_write_semaphore = Arc::new(Semaphore::new(2)); - let did = "did:key:z6MkF3DosPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6f3dos"; // Push A: drive its handler future in slices until it reaches receive-pack (it now // holds the lease and one write permit and is hung). available_permits() drops to 1. @@ -8166,7 +8190,7 @@ mod tests { "the identity key must differ from the row id, or this test proves nothing" ); - let did = "did:key:z6MkU2KeyPusherAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6u2key"; let peer: SocketAddr = "203.0.113.91:5000".parse().unwrap(); let handle = tokio::spawn({ let st = state.clone(); @@ -8247,7 +8271,7 @@ mod tests { State(state), Path(("z6ovflow".to_string(), "o1".to_string())), Extension(crate::auth::AuthenticatedDid( - "did:key:z6MkOverflowPusherAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "did:key:z6ovflow".to_string(), )), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), @@ -8294,7 +8318,7 @@ mod tests { let r = state.db.get_repo("z6u1cap", "c1").await.unwrap().unwrap(); crate::state::repo_identity_key(&r.owner_did, &r.name) }; - let did = "did:key:z6MkU1CapPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6u1cap"; let push = |peer: SocketAddr| { let st = state.clone(); let did = did.to_string(); @@ -8398,7 +8422,7 @@ mod tests { crate::state::repo_identity_key(&r.owner_did, &r.name) }; f1_add_repo(&state, "z6u1two", "r2").await; - let did = "did:key:z6MkU1TwoPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6u1two"; let src: SocketAddr = "203.0.113.84:5000".parse().unwrap(); let push = |repo: &'static str| { let st = state.clone(); @@ -8459,8 +8483,11 @@ mod tests { /// the per-source permit above the park, four parked pushes deny every push on the /// node for up to 1260s. /// - /// Same one source IP for all three pushes (the collapsed-key shape), distinct pusher - /// DIDs, per-source cap 2 so the holder plus a parked push would exhaust it. + /// Same one source IP for all three pushes (the collapsed-key shape), per-source + /// cap 2 so the holder plus a parked push would exhaust it. A and B are the owner + /// of `n1`; C is a second tenant, `z6u1nat2`, pushing to its own `n2` — owner-only + /// push means a tenant is identified by the repo it may write, not by an arbitrary + /// DID, and the cap keys on the resolved peer address rather than either. #[cfg(unix)] #[sqlx::test] async fn u1_parked_push_does_not_shed_another_pusher_behind_the_same_ip(pool: sqlx::PgPool) { @@ -8479,16 +8506,22 @@ mod tests { let r = state.db.get_repo("z6u1nat", "n1").await.unwrap().unwrap(); crate::state::repo_identity_key(&r.owner_did, &r.name) }; - f1_add_repo(&state, "z6u1nat", "n2").await; - // Every pusher arrives from the one edge IP, so they share a source key. + // n2 belongs to a DIFFERENT owner, so the third push is a genuinely different + // tenant — which is what this test is named for. Owner-only push forces each + // push to sign as the owner of the repo it targets, so the tenants are + // distinguished by which repo they push to rather than by an arbitrary DID. + f1_add_repo(&state, "z6u1nat2", "n2").await; + // Every pusher arrives from the one edge IP, so they share a source key: the + // per-source cap keys on the resolved peer address and never on the DID, which + // is exactly the property under test. let edge: SocketAddr = "203.0.113.85:5000".parse().unwrap(); - let push = |pusher: &'static str, repo: &'static str| { + let push = |owner: &'static str, repo: &'static str| { let st = state.clone(); async move { git_receive_pack( State(st), - Path(("z6u1nat".to_string(), repo.to_string())), - Extension(crate::auth::AuthenticatedDid(pusher.to_string())), + Path((owner.to_string(), repo.to_string())), + Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some(edge)), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), @@ -8497,18 +8530,12 @@ mod tests { } }; - let handle_a = tokio::spawn(push( - "did:key:z6MkU1NatPusherOneAAAAAAAAAAAAAAAAAAAAAA", - "n1", - )); + let handle_a = tokio::spawn(push("z6u1nat", "n1")); assert!( f1_wait_for(F1_BACKSTOP, || a_inpack.exists()).await, "pusher one never reached receive-pack within {F1_BACKSTOP:?}" ); - let handle_b = tokio::spawn(push( - "did:key:z6MkU1NatPusherTwoAAAAAAAAAAAAAAAAAAAAAA", - "n1", - )); + let handle_b = tokio::spawn(push("z6u1nat", "n1")); assert!( f1_wait_for(F1_BACKSTOP, || state.repo_write_leases.waiters_for(&repo1) == 1) @@ -8516,13 +8543,10 @@ mod tests { "pusher two never parked on n1's contended lease within {F1_BACKSTOP:?}" ); - // A third, unrelated pusher behind the same edge IP, on a different repo. - let c = tokio::time::timeout( - F1_BACKSTOP, - push("did:key:z6MkU1NatPusherThreeAAAAAAAAAAAAAAAAAA", "n2"), - ) - .await - .expect("an unrelated pusher's push to an uncontended repo must not park"); + // A third pusher — a different tenant — behind the same edge IP, on its own repo. + let c = tokio::time::timeout(F1_BACKSTOP, push("z6u1nat2", "n2")) + .await + .expect("an unrelated pusher's push to an uncontended repo must not park"); let resp = c.unwrap_or_else(|e| { panic!( "U1 scenario 3 RED: one repo's parked push shed an UNRELATED pusher's push \ @@ -8572,7 +8596,11 @@ mod tests { .unwrap(); } - let did = "did:key:z6MkF1KeyPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; + // The pusher must be the repos' owner: this test is about the write-cap key's + // shape, so the push has to reach the cap rather than stop at the owner-push + // gate, which is on by default. `did_matches` collapses the `did:key:` prefix, + // so this is the same identity as the bare `z6f1key` the rows are owned by. + let did = "did:key:z6f1key"; let capped: SocketAddr = "203.0.113.65:5000".parse().unwrap(); let other: SocketAddr = "203.0.113.66:5000".parse().unwrap(); let _slot = state @@ -8604,10 +8632,13 @@ mod tests { ); } let r = push(other, "k2").await; + // Positive, for the same reason as `f1_write_cap_is_inert_without_a_resolvable + // _source_key`: `!matches!(.., Overloaded)` is satisfied by a Forbidden, so a + // gate that started rejecting this pusher would leave the test green and inert. assert!( - !matches!(r, Err(AppError::Overloaded(_))), + matches!(r, Err(AppError::Git(_))), "a different source must not be shed on any repo while the capped source \ - holds its slot; got {r:?}" + holds its slot, and must reach git; got {r:?}" ); } @@ -8642,17 +8673,22 @@ mod tests { State(state.clone()), Path(("z6f1none".to_string(), "n1".to_string())), Extension(crate::auth::AuthenticatedDid( - "did:key:z6MkF1NoKeyPusherAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + "did:key:z6f1none".to_string(), )), crate::rate_limit::PeerAddr(None), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), ) .await; + // Assert the POSITIVE outcome, not the absence of one error. Reaching git on + // the missing on-disk repo is proof the request cleared BOTH the owner gate + // and the per-caller cap. The previous shape — `!matches!(r, Overloaded)` — + // was satisfied by any other error, so when owner-only push began rejecting + // this pusher the test kept reporting ok while measuring nothing. assert!( - !matches!(r, Err(AppError::Overloaded(_))), + matches!(r, Err(AppError::Git(_))), "a caller with no resolvable source key must fall back to the global write \ - pool only, never shed on the per-caller cap; got {r:?}" + pool only, never shed on the per-caller cap, and must reach git; got {r:?}" ); } @@ -8673,7 +8709,7 @@ mod tests { let mut state = f4_state_with_repo(pool.clone(), tmp.path(), &git_bin, "z6f1seq", "s1", false).await; state.git_write_per_caller = crate::rate_limit::PerCallerConcurrency::new(1, 100); - let did = "did:key:z6MkF1SeqPusherAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let did = "did:key:z6f1seq"; let src: SocketAddr = "203.0.113.68:5000".parse().unwrap(); for attempt in 1..=2 { @@ -9177,7 +9213,9 @@ mod tests { (state, log) } - const P2_PUSHER: &str = "did:key:z6MkP2TailPusherAAAAAAAAAAAAAAAAAAAAAA"; + // No shared pusher constant: the two P2 tests own different repos (`z6p2tail` + // and `z6p2fail`), and owner-only push is on by default, so the identity has to + // follow the repo each push targets rather than being fixed for both. fn p2_push( state: &AppState, @@ -9190,7 +9228,7 @@ mod tests { git_receive_pack( State(state.clone()), Path((owner.to_string(), name.to_string())), - Extension(crate::auth::AuthenticatedDid(P2_PUSHER.to_string())), + Extension(crate::auth::AuthenticatedDid(format!("did:key:{owner}"))), crate::rate_limit::PeerAddr(Some("203.0.113.90:5000".parse::().unwrap())), axum::http::HeaderMap::new(), axum::body::Bytes::from_static(b"0000"), diff --git a/crates/gitlawb-node/src/config.rs b/crates/gitlawb-node/src/config.rs index fefa063e..13a1ce7b 100644 --- a/crates/gitlawb-node/src/config.rs +++ b/crates/gitlawb-node/src/config.rs @@ -78,10 +78,26 @@ pub struct Config { /// Require the authenticated pusher to be the repo owner on `git-receive-pack`. /// Authentication (a valid did:key signature) is not authorization on its own: - /// any party can sign as their own DID. When true, pushes whose authenticated - /// DID is not the repo owner are rejected. Keep false during rolling upgrades; - /// flip it on once owners are ready for owner-only writes. - #[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)] + /// any party can mint a did:key and sign as it, so with this off every signed + /// caller may push to every repository, private ones included. On by default. + /// + /// Turn it off only for a rolling upgrade whose pushers are not yet the repo + /// owner. Both `GITLAWB_ENFORCE_OWNER_PUSH=false` and `--enforce-owner-push + /// false` disable it, and the bare `--enforce-owner-push` form still means + /// `true`. + /// + /// The value-taking action is what makes the CLI form parse at all: as a + /// presence-only flag, `--enforce-owner-push false` is an "unexpected argument" + /// error. The env form resolved correctly either way, so it is the CLI escape + /// hatch this buys, not the environment one. + #[arg( + long, + env = "GITLAWB_ENFORCE_OWNER_PUSH", + action = clap::ArgAction::Set, + num_args = 0..=1, + default_value_t = true, + default_missing_value = "true" + )] pub enforce_owner_push: bool, /// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001) @@ -973,4 +989,51 @@ mod tests { "db_max_connections at the floor (pushes + headroom) must validate" ); } + + /// The DECLARED default, read off the parser rather than out of a parse. + /// + /// `Config::parse_from` consults the process environment, so on a host that + /// exports `GITLAWB_ENFORCE_OWNER_PUSH=false` a parse-based assertion says + /// nothing about what this crate declares — it reports the operator's setting. + /// Asserting the declaration is the env-independent form, and it is the one that + /// actually fails if someone flips `default_value_t` back. + #[test] + fn enforce_owner_push_is_declared_true_independent_of_the_environment() { + use clap::CommandFactory; + let cmd = Config::command(); + let arg = cmd + .get_arguments() + .find(|a| a.get_id() == "enforce_owner_push") + .expect("the argument must exist"); + assert_eq!( + arg.get_default_values(), + ["true"], + "owner-only push must be the declared default; a node started with no \ + configuration cannot accept a push from a self-minted key" + ); + } + + /// The flip must not strand an operator mid-upgrade. + /// + /// Turning the gate on is a breaking change for any deployment whose pushers are + /// not yet the repo owner, so the escape hatch has to keep working. As a + /// presence-only flag `--enforce-owner-push false` is not "false", it is an + /// "unexpected argument" error; the value-taking action is what makes that form + /// parse. + /// + /// This pins the CLI form only. The environment form resolved to `false` under + /// the presence-only declaration too, so it is not what this change fixed, and it + /// is not exercised here because the process environment is global and these + /// tests run in parallel. + #[test] + fn enforce_owner_push_stays_disableable_for_rolling_upgrades() { + assert!( + !Config::parse_from(["gitlawb-node", "--enforce-owner-push", "false"]) + .enforce_owner_push, + "operators must still be able to opt out during a rolling upgrade" + ); + assert!( + Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push + ); + } } diff --git a/crates/gitlawb-node/src/test_support.rs b/crates/gitlawb-node/src/test_support.rs index 2b5aef95..99b4a477 100644 --- a/crates/gitlawb-node/src/test_support.rs +++ b/crates/gitlawb-node/src/test_support.rs @@ -30,6 +30,12 @@ use crate::state::AppState; /// Build an [`AppState`] over a real, migrated Postgres pool (from `#[sqlx::test]`). /// Runs the schema migrations first, because the per-test database starts empty. +/// +/// **The config here is parsed from the process environment.** `Config` sources 47 +/// fields from `GITLAWB_*` variables, so a developer or CI environment that sets one +/// silently changes what this state does. Never assert a behaviour that a config +/// field controls on top of this state — use [`test_state_with`] and set the field, +/// so the test states the configuration it is about instead of inheriting it. pub(crate) async fn test_state(pool: PgPool) -> AppState { let db = Arc::new(crate::db::Db::for_testing(pool.clone())); db.run_migrations() @@ -38,6 +44,24 @@ pub(crate) async fn test_state(pool: PgPool) -> AppState { build_state(db, pool) } +/// [`test_state`] with an explicit config override, so a test that depends on a +/// setting pins it rather than inheriting whatever the environment supplies. +/// +/// The shipped default is proved separately and env-independently by +/// `config::tests::enforce_owner_push_is_declared_true_independent_of_the_environment`, +/// which reads the declaration off the parser. Splitting the two keeps a parser +/// question and an authorization question from sharing one failure mode. +pub(crate) async fn test_state_with( + pool: PgPool, + configure: impl FnOnce(&mut crate::config::Config), +) -> AppState { + let mut state = test_state(pool).await; + let mut cfg = (*state.config).clone(); + configure(&mut cfg); + state.config = Arc::new(cfg); + state +} + /// DB-free [`AppState`] for middleware/auth tests that return before any query. /// The pool is lazy and never connects — do NOT use for tests that hit the DB. // Harness API consumed by the plan-002/003 middleware and no-auth-rejection tests. @@ -2172,6 +2196,71 @@ mod tests { ); } + /// The handler wiring: with the gate on, a signed non-owner push is refused. + /// + /// The gate's own unit tests pass `enforce` as a literal and never exercise the + /// handler, so this is what proves the flag is actually consulted on the request + /// path. It sets the field EXPLICITLY rather than leaning on the default: on a + /// host exporting `GITLAWB_ENFORCE_OWNER_PUSH=false` — which is exactly what the + /// rolling-upgrade guidance tells operators to set — an ambient config would + /// build a disabled state, let the push through to git, and fail this test for a + /// reason that has nothing to do with the code under test. + /// + /// The shipped default is proved env-independently in `config::tests`, off the + /// parser declaration. Authorization behaviour and parser defaults are separate + /// questions and get separate tests. + /// + /// 403 rather than 401 is the discriminator: the request carries a real RFC 9421 + /// signature and passes `require_signature`, so it is authenticated and refused on + /// authorization — which is the whole distinction the change rests on. + #[sqlx::test] + async fn enforced_owner_push_refuses_a_signed_non_owner(pool: PgPool) { + use gitlawb_core::http_sig::sign_request; + use gitlawb_core::identity::Keypair; + + let owner = Keypair::generate(); + let stranger = Keypair::generate(); + let owner_did = owner.did().to_string(); + let short = owner_did.split(':').next_back().unwrap().to_string(); + + let state = test_state_with(pool, |cfg| cfg.enforce_owner_push = true).await; + state + .db + .create_repo(&seed_repo(&owner_did, "defrepo")) + .await + .expect("seed repo"); + + let router = Router::new() + .route( + "/{owner}/{repo}/git-receive-pack", + axum::routing::post(crate::api::repos::git_receive_pack), + ) + .layer(axum::middleware::from_fn(crate::auth::require_signature)) + .with_state(state); + + let path = format!("/{short}/defrepo.git/git-receive-pack"); + let body = b"0000".to_vec(); + let signed = sign_request(&stranger, "POST", &path, &body); + let req = Request::builder() + .method(Method::POST) + .uri(&path) + .header("content-type", "application/x-git-receive-pack-request") + .header("content-digest", signed.content_digest) + .header("signature-input", signed.signature_input) + .header("signature", signed.signature) + .body(Body::from(body)) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "with the gate enabled, a signed push from a non-owner must be refused: \ + a did:key is self-certifying, so authentication alone is not \ + authorization" + ); + } + /// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after /// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack /// POST against a private repo is denied (404), so signing only the Phase-1 diff --git a/docker-compose.yml b/docker-compose.yml index 58f1e0ce..b6466afe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,10 @@ services: GITLAWB_BOOTSTRAP_PEERS: ${GITLAWB_BOOTSTRAP_PEERS:-} GITLAWB_BOOTSTRAP_DISABLE_SEEDS: ${GITLAWB_BOOTSTRAP_DISABLE_SEEDS:-false} GITLAWB_REQUIRE_SIGNED_PEER_WRITES: ${GITLAWB_REQUIRE_SIGNED_PEER_WRITES:-false} + # Owner-only push. The default matches the node's own, so the container path + # behaves like a source build; forwarding it is what makes the documented + # rolling-upgrade opt-out reachable for an operator running compose. + GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true} GITLAWB_P2P_BOOTSTRAP: ${GITLAWB_P2P_BOOTSTRAP:-} GITLAWB_PUBLIC_READ: ${GITLAWB_PUBLIC_READ:-true} GITLAWB_MAX_PACK_BYTES: ${GITLAWB_MAX_PACK_BYTES:-2147483648} diff --git a/docs/OSS-READINESS-AUDIT.md b/docs/OSS-READINESS-AUDIT.md index 0a0bc5f2..e1bcc9c2 100644 --- a/docs/OSS-READINESS-AUDIT.md +++ b/docs/OSS-READINESS-AUDIT.md @@ -88,7 +88,7 @@ Fixed or staged in this pass: Live-network blockers to prioritize: - GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface. -- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization; unprotected repo branches do not yet enforce owner/UCAN capability checks. +- Push authorization is still not capability-complete. A valid DID signature is authentication, not authorization. Owner checks are now enforced on every branch, protected or not (`GITLAWB_ENFORCE_OWNER_PUSH`, on by default); what remains is that a UCAN `git/push` capability is not yet honored, so a delegated or CI key cannot push. - UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature. - Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired. - Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL. diff --git a/docs/RUN-A-NODE.md b/docs/RUN-A-NODE.md index 0a8a0f77..bde62d73 100644 --- a/docs/RUN-A-NODE.md +++ b/docs/RUN-A-NODE.md @@ -141,31 +141,43 @@ During the cooldown your node still earns rewards if it keeps heartbeating. --- -## Hardening: owner-only push +## Owner-only push -By default the node authenticates every `git-receive-pack` push (a valid RFC 9421 -`did:key` signature) but does **not** check that the pusher owns the repo, except -on branches that are explicitly protected. Because `did:key` is self-certifying, -any party can generate a key, derive its DID, sign, and push to an unprotected -branch — authentication is not authorization. +The node requires the authenticated pusher to be the repo owner on **every** +branch. A push whose authenticated DID is not the repo owner is rejected with +HTTP 403 before any ref update is applied. The owner is matched in both the full +`did:key:z6Mk…` form and its bare `z6Mk…` suffix. -To require the authenticated pusher to be the repo owner on **every** branch, set: +This is on by default, and the default is the point. The node authenticates every +`git-receive-pack` push with a valid RFC 9421 `did:key` signature, but `did:key` +is self-certifying: any party can generate a key, derive its DID and sign. +Authentication is not authorization, so without this gate every signed caller can +push to every repository — private ones included — on any branch that is not +explicitly protected. + +### Turning it off ```bash -GITLAWB_ENFORCE_OWNER_PUSH=true +GITLAWB_ENFORCE_OWNER_PUSH=false ``` -- **Default `false`** — preserves current behavior so live nodes are unaffected by - an upgrade. Turn it on once you're ready for owner-only writes. -- **When `true`** — a push whose authenticated DID is not the repo owner is - rejected (HTTP 403) before any ref update is applied. The owner is matched in - both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix. -- **Caution: this blocks every non-owner pusher, including your own delegated and - CI agents.** Push authorization is owner-only today — a UCAN `git/push` - capability is verified but not yet honored for authorization, so delegated keys - cannot push while this is on. Don't enable it until every identity that pushes - to your repos is the owner, or you'll lock out your own automation. Scoped - collaborator / UCAN-delegated push rights are a planned follow-up. +Both the environment variable and `--enforce-owner-push false` disable it; the +bare `--enforce-owner-push` flag still means `true`. + +Do this only for a rolling upgrade whose pushers are not yet the repo owner, and +treat it as temporary: while it is off, your node accepts a push to any repository +from anyone who can generate a keypair. + +### Caution: delegated and CI keys are non-owners + +Push authorization is owner-only today. A UCAN `git/push` capability is verified +but **not yet honored for authorization**, so a delegated key cannot push while +this gate is on, even when it holds a valid capability for the repo. + +If your automation pushes under its own DID rather than the owner's, it will start +getting 403s. Either have it push as the owner, or set +`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push +rights land — that work is what removes this trade-off. --- diff --git a/infra/aws/README.md b/infra/aws/README.md index 75dadd7e..d8d34844 100644 --- a/infra/aws/README.md +++ b/infra/aws/README.md @@ -91,13 +91,39 @@ User-data only runs at first boot, so upgrades go through SSM: $(terraform output -raw upgrade_command) ``` -This runs `docker compose pull && docker compose up -d` on the instance. +This reinstalls the rendered `/opt/gitlawb/compose.yaml`, then runs +`docker compose pull && docker compose up -d` on the instance. + +**Run `terraform apply` before the upgrade command.** The command is an SSM +document managed by Terraform, and it embeds the compose file rendered from your +current variables. An instance that has not had a fresh `apply` still holds the +previous document and will reinstall the previous compose file. - With `image_tag = "latest"` (default) that picks up the newest release. -- With a **pinned tag**, first edit the tag in `/opt/gitlawb/compose.yaml` on - the instance (via SSM session), then run the upgrade command — and keep - `image_tag` in terraform.tfvars in sync so a future instance replacement - boots the same version. +- With a **pinned tag**, set `image_tag` in `terraform.tfvars` and `apply` — the + rendered compose carries it, so the upgrade installs the right version. +- **`compose.yaml` is Terraform-owned and is overwritten on every upgrade.** + Per-instance settings belong in `/opt/gitlawb/.env`, which is never touched. +- **The upgrade reconciles the service set, not just the image.** The rendering is + conditional — `postgres` exists only when `db_host` is empty, `caddy` only when + `domain_name` is set — and the command runs `--remove-orphans`. So setting + `use_rds`, or clearing `domain_name`, and then upgrading will **stop and remove** + the local postgres or the TLS terminator. That is the intended outcome of those + variables, but it happens on the upgrade rather than on `apply`. Data survives + either way: both bind-mount under `/mnt/data`. + +### Why the upgrade rewrites the compose file + +`aws_instance` deliberately ignores `user_data` drift, so an instance keeps the +compose file it was created with. Compose passes only the variables named in a +service's `environment:` block into the container, so **a node setting added to +the template never reaches an existing instance** — writing it to +`/opt/gitlawb/.env` and restarting leaves the node on its built-in default, with +no error to indicate it. `pull && up -d` alone cannot fix that, because the file +on disk is the authoritative one. + +Reinstalling the rendering first makes the upgrade a real migration. It is +idempotent: on an already-current instance it writes identical bytes. Replace the instance itself (OS/AMI/instance-type changes) with `terraform apply -replace=aws_instance.node` — the data volume reattaches and @@ -105,12 +131,24 @@ Replace the instance itself (OS/AMI/instance-type changes) with ## Changing configuration -User-data only runs at first boot, and the instance ignores `user_data` drift -(`ignore_changes`), so editing terraform.tfvars values that feed the bootstrap -(`bootstrap_peers`, `public_url`, integrations, `image_tag`) does **not** -affect a running instance on `terraform apply`. To roll out such changes, -either edit `/opt/gitlawb/.env` on the instance (SSM session, then -`docker compose up -d`), or replace the instance: +`terraform apply` alone never changes a running instance: user-data runs once and +the instance ignores `user_data` drift. How a change rolls out depends on which +of the two files carries it. + +**Rendered into `compose.yaml`** — `image_tag`, `gitlawb_port`, `metrics_port`, +`domain_name`, `db_host`, the `icaptcha_*` values. `apply`, then run +`upgrade_command`: it installs the new rendering and restarts. + +```sh +terraform apply +$(terraform output -raw upgrade_command) +``` + +**Written into `/opt/gitlawb/.env` at first boot** — `public_url`, +`bootstrap_peers`, `auto_sync`, `max_pack_bytes`, and the integration secrets. +Editing these in `terraform.tfvars` does **not** reach a running instance, because +nothing rewrites `.env` after the first boot. Either edit `/opt/gitlawb/.env` on +the instance and `docker compose up -d`, or replace it: ```sh terraform apply -replace=aws_instance.node @@ -118,6 +156,17 @@ terraform apply -replace=aws_instance.node The data volume reattaches; repos, postgres data, and the identity key survive. +**A node setting the installed `compose.yaml` does not already name** — +`GITLAWB_ENFORCE_OWNER_PUSH` on an instance created before it was added, for +example. Editing `.env` is **not enough**: compose only passes through the +variables its `environment:` block lists, so the value is read from `.env` and +then dropped. Run `apply` plus `upgrade_command` first, which installs a compose +file that names the key; after that, `.env` governs it like any other. + +That last case is the one worth remembering: `.env` reaches the container only for +keys the *installed* compose interpolates, and the upgrade is what makes a newly +added key one of them. + ## Remote state (optional) Local state is the default. To move state to S3: create a versioned bucket, diff --git a/infra/aws/compose.yaml.tftpl b/infra/aws/compose.yaml.tftpl index b848f281..0d1974cc 100644 --- a/infra/aws/compose.yaml.tftpl +++ b/infra/aws/compose.yaml.tftpl @@ -72,6 +72,9 @@ services: GITLAWB_BOOTSTRAP_PEERS: $${GITLAWB_BOOTSTRAP_PEERS} GITLAWB_AUTO_SYNC: $${GITLAWB_AUTO_SYNC} GITLAWB_MAX_PACK_BYTES: $${GITLAWB_MAX_PACK_BYTES} + # Owner-only push. Matches the node's own default; forwarding it is what makes + # the documented rolling-upgrade opt-out reachable on the container path. + GITLAWB_ENFORCE_OWNER_PUSH: $${GITLAWB_ENFORCE_OWNER_PUSH:-true} # On-chain PoS (optional — empty unless set in terraform.tfvars) GITLAWB_CHAIN_RPC_URL: $${GITLAWB_CHAIN_RPC_URL} GITLAWB_CONTRACT_NODE_STAKING: $${GITLAWB_CONTRACT_NODE_STAKING} diff --git a/infra/aws/main.tf b/infra/aws/main.tf index 1316d36b..83d60268 100644 --- a/infra/aws/main.tf +++ b/infra/aws/main.tf @@ -350,10 +350,16 @@ resource "aws_instance" "node" { # ami: new AL2023 releases shouldn't churn the instance; replace # deliberately for OS upgrades. # user_data: only runs at first boot, so re-rendering it on a live - # instance is a pointless stop/start. Config changes that feed user-data - # (bootstrap peers, integrations, image tag) require a deliberate - # `terraform apply -replace=aws_instance.node` — see README "Changing - # configuration". + # instance is a pointless stop/start. How a config change reaches a running + # instance depends on which file carries it: + # - rendered into compose.yaml (image_tag, ports, domain_name, db_host, + # icaptcha_*): `terraform apply` then `upgrade_command`, which + # reinstalls the rendering and restarts. + # - written into /opt/gitlawb/.env at first boot (public_url, + # bootstrap_peers, integrations): nothing rewrites .env afterwards, so + # these need an edit on the instance or + # `terraform apply -replace=aws_instance.node`. + # See README "Changing configuration". ignore_changes = [ami, user_data] } } @@ -437,13 +443,62 @@ resource "aws_ssm_document" "upgrade" { content = jsonencode({ schemaVersion = "2.2" - description = "Pull the latest gitlawb node image and restart the compose stack" + description = "Reinstall the rendered compose file, pull the latest image, and restart" mainSteps = [{ action = "aws:runShellScript" name = "upgrade" inputs = { - runCommand = [ - "cd /opt/gitlawb && docker compose pull && docker compose up -d --remove-orphans && docker image prune -f" + # Reinstall the CURRENT rendering before restarting. + # + # `aws_instance` ignores user_data drift, so an instance created before a + # template change keeps the compose file it was born with. Compose passes + # only the variables named in its `environment:` block into the container, + # so a node setting added to the template never reaches an existing + # instance — the operator writes it to /opt/gitlawb/.env, restarts, and the + # node silently keeps the built-in default. `docker compose pull && up -d` + # cannot fix that: the authoritative file is the one on disk. + # + # Idempotent: writing the same bytes and restarting is a no-op. This does + # overwrite hand-edits to compose.yaml, which is intended — the file is + # Terraform-owned; per-instance settings belong in /opt/gitlawb/.env. + # + # One shell step, under flock, writing through mktemp. Two overlapping + # executions — a double run, a retry while the first is still pulling, two + # operators — otherwise share one fixed temp path: the second truncates the + # file the first is about to install, and once the first renames, the + # second's open descriptor keeps writing into the live compose.yaml inode. + # Both outcomes stay valid YAML, so the `up -d --remove-orphans` on the next + # line accepts a blended or truncated service set and deletes whatever fell + # off — on a live node, the local postgres or the TLS terminator. The lock + # also covers pull and up, so a second run cannot restart against a file the + # first is mid-install. + runCommand = [<<-UPGRADE + set -euo pipefail + install -d -m 0755 /opt/gitlawb + + exec 9>/opt/gitlawb/.upgrade.lock + if ! flock -n 9; then + echo "another upgrade is already running on this instance" >&2 + exit 1 + fi + + tmp="$(mktemp /opt/gitlawb/compose.yaml.XXXXXX)" + trap 'rm -f "$tmp"' EXIT + # base64 rather than a nested heredoc: the rendered compose contains + # `$${VAR}` passthroughs that must reach the file unexpanded, and a + # heredoc inside an indented Terraform heredoc depends on the dedent + # landing the delimiter at column 0. Decoding a single line has neither + # failure mode, and no delimiter can collide with the content. + echo '${base64encode(local.compose_yaml)}' | base64 -d > "$tmp" + chmod 0644 "$tmp" + mv "$tmp" /opt/gitlawb/compose.yaml + trap - EXIT + + cd /opt/gitlawb + docker compose pull + docker compose up -d --remove-orphans + docker image prune -f + UPGRADE ] } }] diff --git a/macos-app/Sources/GitlawbNode/Config.swift b/macos-app/Sources/GitlawbNode/Config.swift index ff76723e..2e975e54 100644 --- a/macos-app/Sources/GitlawbNode/Config.swift +++ b/macos-app/Sources/GitlawbNode/Config.swift @@ -19,6 +19,11 @@ class Config { var operatorPrivateKey: String = "" var tigrisBucket: String = "" var autoSync: Bool = false + /// Owner-only push. Persisted as a setting rather than read from a hand-edited + /// `.env`, because `writeEnvFile()` regenerates that file on every start and + /// would discard the edit. Matches the node's own default; set false only for a + /// rolling upgrade whose pushers are not yet the repo owner. + var enforceOwnerPush: Bool = true var repoPathString: String = "" // MARK: - Paths @@ -76,6 +81,7 @@ class Config { "operatorPrivateKey": operatorPrivateKey, "tigrisBucket": tigrisBucket, "autoSync": autoSync, + "enforceOwnerPush": enforceOwnerPush, "repoPath": repoPathString, ] @@ -105,6 +111,7 @@ class Config { operatorPrivateKey = dict["operatorPrivateKey"] as? String ?? operatorPrivateKey tigrisBucket = dict["tigrisBucket"] as? String ?? tigrisBucket autoSync = dict["autoSync"] as? Bool ?? autoSync + enforceOwnerPush = dict["enforceOwnerPush"] as? Bool ?? enforceOwnerPush repoPathString = dict["repoPath"] as? String ?? repoPathString } @@ -121,6 +128,7 @@ class Config { lines.append("GITLAWB_PORT=\(httpPort)") lines.append("GITLAWB_P2P_PORT=\(p2pPort)") lines.append("GITLAWB_AUTO_SYNC=\(autoSync)") + lines.append("GITLAWB_ENFORCE_OWNER_PUSH=\(enforceOwnerPush)") if !chainRpcURL.isEmpty { lines.append("GITLAWB_CHAIN_RPC_URL=\(chainRpcURL)") diff --git a/macos-app/Sources/GitlawbNode/DockerCompose.swift b/macos-app/Sources/GitlawbNode/DockerCompose.swift index fd1b8a25..7a3bdc9d 100644 --- a/macos-app/Sources/GitlawbNode/DockerCompose.swift +++ b/macos-app/Sources/GitlawbNode/DockerCompose.swift @@ -294,6 +294,10 @@ services: GITLAWB_HOST: 0.0.0.0 GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545} GITLAWB_P2P_PORT: 7546 + # Owner-only push. Must match the bundled Resources/docker-compose.yml: this + # generated file — not the bundled resource — is what a packaged app runs, so + # omitting it here leaves the documented opt-out unreachable for those users. + GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true} GITLAWB_CHAIN_RPC_URL: ${GITLAWB_CHAIN_RPC_URL:-} GITLAWB_CONTRACT_NODE_STAKING: ${GITLAWB_CONTRACT_NODE_STAKING:-} GITLAWB_OPERATOR_PRIVATE_KEY: ${GITLAWB_OPERATOR_PRIVATE_KEY:-} diff --git a/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml b/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml index 35a1e1c4..9cd27bab 100644 --- a/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml +++ b/macos-app/Sources/GitlawbNode/Resources/docker-compose.yml @@ -33,6 +33,9 @@ services: GITLAWB_PORT: ${GITLAWB_PORT:-7545} GITLAWB_PUBLIC_URL: ${GITLAWB_PUBLIC_URL:-http://localhost:7545} GITLAWB_P2P_PORT: ${GITLAWB_P2P_PORT:-7546} + # Owner-only push. Matches the node's own default; forwarding it is what makes + # the documented rolling-upgrade opt-out reachable on the container path. + GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true} # Sync GITLAWB_AUTO_SYNC: ${GITLAWB_AUTO_SYNC:-false} # On-chain PoS (optional — leave unset for local/dev) diff --git a/macos-app/Sources/GitlawbNode/SettingsWindow.swift b/macos-app/Sources/GitlawbNode/SettingsWindow.swift index 6adeade4..26ffc365 100644 --- a/macos-app/Sources/GitlawbNode/SettingsWindow.swift +++ b/macos-app/Sources/GitlawbNode/SettingsWindow.swift @@ -28,6 +28,7 @@ struct SettingsView: View { @State private var operatorPrivateKey: String = Config.shared.operatorPrivateKey @State private var tigrisBucket: String = Config.shared.tigrisBucket @State private var autoSync: Bool = Config.shared.autoSync + @State private var enforceOwnerPush: Bool = Config.shared.enforceOwnerPush var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -53,6 +54,15 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) } + VStack(alignment: .leading, spacing: 2) { + Toggle("Require repo owner to push", isOn: $enforceOwnerPush) + Text("A signature proves who is pushing, not that they own the repo. " + + "Turn this off only during a rolling upgrade whose pushers are " + + "not yet the owner — while off, anyone who can sign may push to " + + "any repo on this node.") + .font(.caption) + .foregroundColor(.secondary) + } Divider() @@ -96,6 +106,7 @@ struct SettingsView: View { Config.shared.operatorPrivateKey = operatorPrivateKey Config.shared.tigrisBucket = tigrisBucket Config.shared.autoSync = autoSync + Config.shared.enforceOwnerPush = enforceOwnerPush Config.shared.persist() Config.shared.writeEnvFile()