Skip to content

Commit 73fd747

Browse files
authored
Merge pull request #330 from Gitlawb/fix/default-enforce-owner-push
fix(node)!: enforce owner-only push by default
2 parents 21c74c6 + 888fab6 commit 73fd747

15 files changed

Lines changed: 444 additions & 98 deletions

File tree

‎.env.example‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,14 @@ GITLAWB_REQUIRE_SIGNED_PEER_WRITES=false
9494

9595
# Require the authenticated pusher to be the repo owner on git-receive-pack.
9696
# A valid did:key signature is authentication, not authorization: anyone can
97-
# sign as their own DID. When true, pushes from a non-owner DID are rejected.
98-
# Keep false until the repo owner is ready for owner-only writes.
99-
GITLAWB_ENFORCE_OWNER_PUSH=false
97+
# mint a key, derive its DID and sign, so with this off every signed caller may
98+
# push to every repository, private ones included. On by default.
99+
#
100+
# Set to false only for a rolling upgrade whose pushers are not yet the repo
101+
# owner. Note that delegated and CI keys count as non-owners: UCAN git/push is
102+
# verified but not yet honored for authorization, so they cannot push while this
103+
# is on. See docs/RUN-A-NODE.md.
104+
GITLAWB_ENFORCE_OWNER_PUSH=true
100105

101106
# Comma-separated libp2p multiaddrs.
102107
# Example: /ip4/1.2.3.4/udp/7546/quic-v1/p2p/12D3KooW...
@@ -191,8 +196,9 @@ GITLAWB_MAX_CONCURRENT_READS_PER_CALLER=16
191196
# many WRITE-pool slots. This one is load-bearing for push availability, since
192197
# it is acquired before the global write permit: without it, one host minting
193198
# disposable did:key identities could open enough slow pushes to monopolize the
194-
# write pool and 503 every other source (owner enforcement defaults off, and
195-
# the push rate limiter caps arrival rate, not in-flight concurrency).
199+
# write pool and 503 every other source (the push rate limiter caps arrival
200+
# rate, not in-flight concurrency, and owner enforcement can be turned off for
201+
# a rolling upgrade).
196202
# Keyed on the resolved source IP, never the DID, so a DID farm does not defeat
197203
# them; keying granularity follows GITLAWB_TRUSTED_PROXY like the read cap above.
198204

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ Important node settings:
392392
| `GITLAWB_BOOTSTRAP_PEERS` | Comma-separated HTTP peer URLs. |
393393
| `GITLAWB_P2P_BOOTSTRAP` | Comma-separated libp2p multiaddrs. |
394394
| `GITLAWB_BOOTSTRAP_DISABLE_SEEDS` | Disable embedded seed peers for isolated dev/test networks. |
395-
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. |
395+
| `GITLAWB_REQUIRE_SIGNED_PEER_WRITES` | Require signed peer announce/sync writes. Defaults to `false` during the staged rollout below. |
396+
| `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). |
396397
| `GITLAWB_AUTO_SYNC` | Enable automatic sync from known peers. |
397398
| `GITLAWB_MAX_PACK_BYTES` | Max git pack body size for smart-HTTP routes. |
398399
| `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. |

‎crates/gitlawb-node/src/api/repos.rs‎

Lines changed: 88 additions & 50 deletions
Large diffs are not rendered by default.

‎crates/gitlawb-node/src/config.rs‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,26 @@ pub struct Config {
7878

7979
/// Require the authenticated pusher to be the repo owner on `git-receive-pack`.
8080
/// Authentication (a valid did:key signature) is not authorization on its own:
81-
/// any party can sign as their own DID. When true, pushes whose authenticated
82-
/// DID is not the repo owner are rejected. Keep false during rolling upgrades;
83-
/// flip it on once owners are ready for owner-only writes.
84-
#[arg(long, env = "GITLAWB_ENFORCE_OWNER_PUSH", default_value_t = false)]
81+
/// any party can mint a did:key and sign as it, so with this off every signed
82+
/// caller may push to every repository, private ones included. On by default.
83+
///
84+
/// Turn it off only for a rolling upgrade whose pushers are not yet the repo
85+
/// owner. Both `GITLAWB_ENFORCE_OWNER_PUSH=false` and `--enforce-owner-push
86+
/// false` disable it, and the bare `--enforce-owner-push` form still means
87+
/// `true`.
88+
///
89+
/// The value-taking action is what makes the CLI form parse at all: as a
90+
/// presence-only flag, `--enforce-owner-push false` is an "unexpected argument"
91+
/// error. The env form resolved correctly either way, so it is the CLI escape
92+
/// hatch this buys, not the environment one.
93+
#[arg(
94+
long,
95+
env = "GITLAWB_ENFORCE_OWNER_PUSH",
96+
action = clap::ArgAction::Set,
97+
num_args = 0..=1,
98+
default_value_t = true,
99+
default_missing_value = "true"
100+
)]
85101
pub enforce_owner_push: bool,
86102

87103
/// URL of local IPFS/Kubo node HTTP API (e.g. http://127.0.0.1:5001)
@@ -1384,4 +1400,51 @@ mod tests {
13841400
"db_max_connections at the floor (pushes + headroom) must validate"
13851401
);
13861402
}
1403+
1404+
/// The DECLARED default, read off the parser rather than out of a parse.
1405+
///
1406+
/// `Config::parse_from` consults the process environment, so on a host that
1407+
/// exports `GITLAWB_ENFORCE_OWNER_PUSH=false` a parse-based assertion says
1408+
/// nothing about what this crate declares — it reports the operator's setting.
1409+
/// Asserting the declaration is the env-independent form, and it is the one that
1410+
/// actually fails if someone flips `default_value_t` back.
1411+
#[test]
1412+
fn enforce_owner_push_is_declared_true_independent_of_the_environment() {
1413+
use clap::CommandFactory;
1414+
let cmd = Config::command();
1415+
let arg = cmd
1416+
.get_arguments()
1417+
.find(|a| a.get_id() == "enforce_owner_push")
1418+
.expect("the argument must exist");
1419+
assert_eq!(
1420+
arg.get_default_values(),
1421+
["true"],
1422+
"owner-only push must be the declared default; a node started with no \
1423+
configuration cannot accept a push from a self-minted key"
1424+
);
1425+
}
1426+
1427+
/// The flip must not strand an operator mid-upgrade.
1428+
///
1429+
/// Turning the gate on is a breaking change for any deployment whose pushers are
1430+
/// not yet the repo owner, so the escape hatch has to keep working. As a
1431+
/// presence-only flag `--enforce-owner-push false` is not "false", it is an
1432+
/// "unexpected argument" error; the value-taking action is what makes that form
1433+
/// parse.
1434+
///
1435+
/// This pins the CLI form only. The environment form resolved to `false` under
1436+
/// the presence-only declaration too, so it is not what this change fixed, and it
1437+
/// is not exercised here because the process environment is global and these
1438+
/// tests run in parallel.
1439+
#[test]
1440+
fn enforce_owner_push_stays_disableable_for_rolling_upgrades() {
1441+
assert!(
1442+
!Config::parse_from(["gitlawb-node", "--enforce-owner-push", "false"])
1443+
.enforce_owner_push,
1444+
"operators must still be able to opt out during a rolling upgrade"
1445+
);
1446+
assert!(
1447+
Config::parse_from(["gitlawb-node", "--enforce-owner-push", "true"]).enforce_owner_push
1448+
);
1449+
}
13871450
}

‎crates/gitlawb-node/src/test_support.rs‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ use crate::state::AppState;
3030

3131
/// Build an [`AppState`] over a real, migrated Postgres pool (from `#[sqlx::test]`).
3232
/// Runs the schema migrations first, because the per-test database starts empty.
33+
///
34+
/// **The config here is parsed from the process environment.** `Config` sources 47
35+
/// fields from `GITLAWB_*` variables, so a developer or CI environment that sets one
36+
/// silently changes what this state does. Never assert a behaviour that a config
37+
/// field controls on top of this state — use [`test_state_with`] and set the field,
38+
/// so the test states the configuration it is about instead of inheriting it.
3339
pub(crate) async fn test_state(pool: PgPool) -> AppState {
3440
let db = Arc::new(crate::db::Db::for_testing(pool.clone()));
3541
db.run_migrations()
@@ -38,6 +44,24 @@ pub(crate) async fn test_state(pool: PgPool) -> AppState {
3844
build_state(db, pool)
3945
}
4046

47+
/// [`test_state`] with an explicit config override, so a test that depends on a
48+
/// setting pins it rather than inheriting whatever the environment supplies.
49+
///
50+
/// The shipped default is proved separately and env-independently by
51+
/// `config::tests::enforce_owner_push_is_declared_true_independent_of_the_environment`,
52+
/// which reads the declaration off the parser. Splitting the two keeps a parser
53+
/// question and an authorization question from sharing one failure mode.
54+
pub(crate) async fn test_state_with(
55+
pool: PgPool,
56+
configure: impl FnOnce(&mut crate::config::Config),
57+
) -> AppState {
58+
let mut state = test_state(pool).await;
59+
let mut cfg = (*state.config).clone();
60+
configure(&mut cfg);
61+
state.config = Arc::new(cfg);
62+
state
63+
}
64+
4165
/// DB-free [`AppState`] for middleware/auth tests that return before any query.
4266
/// The pool is lazy and never connects — do NOT use for tests that hit the DB.
4367
// Harness API consumed by the plan-002/003 middleware and no-auth-rejection tests.
@@ -2302,6 +2326,71 @@ mod tests {
23022326
);
23032327
}
23042328

2329+
/// The handler wiring: with the gate on, a signed non-owner push is refused.
2330+
///
2331+
/// The gate's own unit tests pass `enforce` as a literal and never exercise the
2332+
/// handler, so this is what proves the flag is actually consulted on the request
2333+
/// path. It sets the field EXPLICITLY rather than leaning on the default: on a
2334+
/// host exporting `GITLAWB_ENFORCE_OWNER_PUSH=false` — which is exactly what the
2335+
/// rolling-upgrade guidance tells operators to set — an ambient config would
2336+
/// build a disabled state, let the push through to git, and fail this test for a
2337+
/// reason that has nothing to do with the code under test.
2338+
///
2339+
/// The shipped default is proved env-independently in `config::tests`, off the
2340+
/// parser declaration. Authorization behaviour and parser defaults are separate
2341+
/// questions and get separate tests.
2342+
///
2343+
/// 403 rather than 401 is the discriminator: the request carries a real RFC 9421
2344+
/// signature and passes `require_signature`, so it is authenticated and refused on
2345+
/// authorization — which is the whole distinction the change rests on.
2346+
#[sqlx::test]
2347+
async fn enforced_owner_push_refuses_a_signed_non_owner(pool: PgPool) {
2348+
use gitlawb_core::http_sig::sign_request;
2349+
use gitlawb_core::identity::Keypair;
2350+
2351+
let owner = Keypair::generate();
2352+
let stranger = Keypair::generate();
2353+
let owner_did = owner.did().to_string();
2354+
let short = owner_did.split(':').next_back().unwrap().to_string();
2355+
2356+
let state = test_state_with(pool, |cfg| cfg.enforce_owner_push = true).await;
2357+
state
2358+
.db
2359+
.create_repo(&seed_repo(&owner_did, "defrepo"))
2360+
.await
2361+
.expect("seed repo");
2362+
2363+
let router = Router::new()
2364+
.route(
2365+
"/{owner}/{repo}/git-receive-pack",
2366+
axum::routing::post(crate::api::repos::git_receive_pack),
2367+
)
2368+
.layer(axum::middleware::from_fn(crate::auth::require_signature))
2369+
.with_state(state);
2370+
2371+
let path = format!("/{short}/defrepo.git/git-receive-pack");
2372+
let body = b"0000".to_vec();
2373+
let signed = sign_request(&stranger, "POST", &path, &body);
2374+
let req = Request::builder()
2375+
.method(Method::POST)
2376+
.uri(&path)
2377+
.header("content-type", "application/x-git-receive-pack-request")
2378+
.header("content-digest", signed.content_digest)
2379+
.header("signature-input", signed.signature_input)
2380+
.header("signature", signed.signature)
2381+
.body(Body::from(body))
2382+
.unwrap();
2383+
2384+
let resp = router.oneshot(req).await.unwrap();
2385+
assert_eq!(
2386+
resp.status(),
2387+
StatusCode::FORBIDDEN,
2388+
"with the gate enabled, a signed push from a non-owner must be refused: \
2389+
a did:key is self-certifying, so authentication alone is not \
2390+
authorization"
2391+
);
2392+
}
2393+
23052394
/// A1 Phase-2 contract: the `git-upload-pack` POST (the actual fetch, after
23062395
/// the advertisement) is itself read-visibility gated. An ANONYMOUS upload-pack
23072396
/// POST against a private repo is denied (404), so signing only the Phase-1

‎docker-compose.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ services:
4444
GITLAWB_BOOTSTRAP_PEERS: ${GITLAWB_BOOTSTRAP_PEERS:-}
4545
GITLAWB_BOOTSTRAP_DISABLE_SEEDS: ${GITLAWB_BOOTSTRAP_DISABLE_SEEDS:-false}
4646
GITLAWB_REQUIRE_SIGNED_PEER_WRITES: ${GITLAWB_REQUIRE_SIGNED_PEER_WRITES:-false}
47+
# Owner-only push. The default matches the node's own, so the container path
48+
# behaves like a source build; forwarding it is what makes the documented
49+
# rolling-upgrade opt-out reachable for an operator running compose.
50+
GITLAWB_ENFORCE_OWNER_PUSH: ${GITLAWB_ENFORCE_OWNER_PUSH:-true}
4751
GITLAWB_P2P_BOOTSTRAP: ${GITLAWB_P2P_BOOTSTRAP:-}
4852
GITLAWB_PUBLIC_READ: ${GITLAWB_PUBLIC_READ:-true}
4953
GITLAWB_MAX_PACK_BYTES: ${GITLAWB_MAX_PACK_BYTES:-2147483648}

‎docs/OSS-READINESS-AUDIT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ Fixed or staged in this pass:
9393
Live-network blockers to prioritize:
9494

9595
- GraphQL POST is still open for compatibility; GraphQL mutations should get mutation-aware auth before it becomes a public write API surface.
96-
- 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.
96+
- 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.
9797
- UCAN chain validation is incomplete and UCAN revocation/blocklisting is not implemented as an operator feature.
9898
- Private repository reads are not enforced. `is_public` and `GITLAWB_PUBLIC_READ` exist, but per-repository private-read behavior is not wired.
9999
- Peer URLs are self-asserted by DIDs. Signatures prove control of the DID key when present, not ownership/safety of the announced URL.

‎docs/RUN-A-NODE.md‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -141,31 +141,43 @@ During the cooldown your node still earns rewards if it keeps heartbeating.
141141

142142
---
143143

144-
## Hardening: owner-only push
144+
## Owner-only push
145145

146-
By default the node authenticates every `git-receive-pack` push (a valid RFC 9421
147-
`did:key` signature) but does **not** check that the pusher owns the repo, except
148-
on branches that are explicitly protected. Because `did:key` is self-certifying,
149-
any party can generate a key, derive its DID, sign, and push to an unprotected
150-
branch — authentication is not authorization.
146+
The node requires the authenticated pusher to be the repo owner on **every**
147+
branch. A push whose authenticated DID is not the repo owner is rejected with
148+
HTTP 403 before any ref update is applied. The owner is matched in both the full
149+
`did:key:z6Mk…` form and its bare `z6Mk…` suffix.
151150

152-
To require the authenticated pusher to be the repo owner on **every** branch, set:
151+
This is on by default, and the default is the point. The node authenticates every
152+
`git-receive-pack` push with a valid RFC 9421 `did:key` signature, but `did:key`
153+
is self-certifying: any party can generate a key, derive its DID and sign.
154+
Authentication is not authorization, so without this gate every signed caller can
155+
push to every repository — private ones included — on any branch that is not
156+
explicitly protected.
157+
158+
### Turning it off
153159

154160
```bash
155-
GITLAWB_ENFORCE_OWNER_PUSH=true
161+
GITLAWB_ENFORCE_OWNER_PUSH=false
156162
```
157163

158-
- **Default `false`** — preserves current behavior so live nodes are unaffected by
159-
an upgrade. Turn it on once you're ready for owner-only writes.
160-
- **When `true`** — a push whose authenticated DID is not the repo owner is
161-
rejected (HTTP 403) before any ref update is applied. The owner is matched in
162-
both the full `did:key:z6Mk…` form and its bare `z6Mk…` suffix.
163-
- **Caution: this blocks every non-owner pusher, including your own delegated and
164-
CI agents.** Push authorization is owner-only today — a UCAN `git/push`
165-
capability is verified but not yet honored for authorization, so delegated keys
166-
cannot push while this is on. Don't enable it until every identity that pushes
167-
to your repos is the owner, or you'll lock out your own automation. Scoped
168-
collaborator / UCAN-delegated push rights are a planned follow-up.
164+
Both the environment variable and `--enforce-owner-push false` disable it; the
165+
bare `--enforce-owner-push` flag still means `true`.
166+
167+
Do this only for a rolling upgrade whose pushers are not yet the repo owner, and
168+
treat it as temporary: while it is off, your node accepts a push to any repository
169+
from anyone who can generate a keypair.
170+
171+
### Caution: delegated and CI keys are non-owners
172+
173+
Push authorization is owner-only today. A UCAN `git/push` capability is verified
174+
but **not yet honored for authorization**, so a delegated key cannot push while
175+
this gate is on, even when it holds a valid capability for the repo.
176+
177+
If your automation pushes under its own DID rather than the owner's, it will start
178+
getting 403s. Either have it push as the owner, or set
179+
`GITLAWB_ENFORCE_OWNER_PUSH=false` until scoped collaborator / UCAN-delegated push
180+
rights land — that work is what removes this trade-off.
169181

170182
---
171183

0 commit comments

Comments
 (0)