From 24a457651555abacf1c1b69798528dbef9fd1b23 Mon Sep 17 00:00:00 2001 From: Jonas Grosch Date: Wed, 30 Sep 2026 23:34:19 +0200 Subject: [PATCH] feat(policy): host baseline push policy judged with every repository's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repository has no policy.json until an admin writes one, so a new repository is allow-all until then, and a per-repo write races the first push. `[policy] baseline = ""` names one more document in the policy.json language. It is read at startup with the same parser and validator; an unreadable or invalid file stops the process and fails `walgit config check`. It is held in memory. Combination: both apply. Receive-pack judges a push against the baseline, then against the repository's own policy.json, and the push must pass both. This is protect's AND carried across documents, so order only decides which rule the ng line names (`rejected by baseline rule ''` comes first). Each document resolves `group:` against its own roster. The language has no admitting effect, so a repository can add restrictions and never lift one: neither a policy.json written for a local reason nor a PUT of an empty document can drop the host's protection. The settings dry-run judges after the baseline the same way. GET …/policy still returns the repository's own document, which the editor round-trips through PUT. The new GET …/policy/effective lists the layers in evaluation order, `{layers: [{source: baseline|repository, policy}]}`. Round trips: unchanged. Receive-pack still does one policy.json GET (a 404 costs nothing) and the baseline adds 0 store requests. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 17 +- README.md | 2 +- crates/walgit-cli/src/config_cmd.rs | 2 + crates/walgit-config/src/lib.rs | 46 ++++ crates/walgit-server/src/lib.rs | 15 ++ crates/walgit-server/src/policy.rs | 369 +++++++++++++++++++++++++-- crates/walgit-server/src/settings.rs | 10 +- crates/walgit-server/src/smart.rs | 7 +- crates/walgit-server/src/web/v1.rs | 11 +- crates/walgit-server/tests/policy.rs | 260 +++++++++++++++++++ docs/POLICY.md | 84 +++++- docs/ROUNDTRIPS.md | 1 + walgit.example.toml | 9 + web/API.md | 8 +- 14 files changed, 801 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fc7d20b..8fea7e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor | `checkpoints//checkpoint.pb`, `refs.pb` | Folded state at `seq`: live pack set + full `RefSnapshot`. Cold start = snapshot + tail, never full replay. | | `leases/.pb` | CAS lease with TTL heartbeat: `compact`. The only cross-instance mutex. | | `cache/api/v1/.json` | Shared render cache of immutable web API answers. | -| `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all. | +| `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all, except for a host `[policy] baseline`, which applies to every repository as well. | | `fsck.pb` | Last connectivity audit (`FsckReport`), written by the maintainer's `fsck` unit, consumed by `repair` (`docs/INTEGRITY.md`). | | `events/cursor.json` | Durable acknowledged WAL sequence of the events bridge; advanced only after the webhook acknowledged (D32). | | `lfs/objects///` | LFS objects (sha256-addressed, immutable). Missing ones can be read through from `upstream.lfs` and persisted (`docs/LFS.md`). | @@ -292,7 +292,8 @@ Unrelated constraints remain in force. The current design target and migration g tasks, policy, settings) so the repository prefix determines placement. The browser lane is `/{o}/{r}/api-browser/…`; `/api/v1` is non-repository discovery, identity and owner listing. No aliases. - **D16** Push authorization is a per-repo **rule language** at `repos///policy.json` (`docs/POLICY.md`). - Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref. + Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref + (unless the host has a baseline: *host baseline push policy*, below). - **D17 (historical; superseded by D42)** `bundles.require` refuses only **unbounded** zero-have fetches (no `deepen*`, no `filter`): that is a full clone and belongs to bundle-uri. Bounded zero-have fetches (CI's `--depth`/`--filter`) go to upload-pack. git never retries a failed bundle download and then falls back to a zero-have fetch; a principal that fetched @@ -487,6 +488,18 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`. and candidate external-boundary proof remain separate obligations; local loose objects and retired download membership cannot justify retirement. See the cost and remaining-evidence rows in the linked docs. +- **D5x — host baseline push policy (2026-09-30; number assigned at merge).** `[policy] baseline = + ""` names one more document in the D16 language, read at startup with the `policy.json` parser and + validator (unreadable or invalid = no start, `walgit config check` fails) and held in memory (0 store + requests). **Both apply:** a push is judged against the baseline, then the repository's own `policy.json`, + and must pass both — the `protect` AND carried across documents, so order only picks the named rule + (`rejected by baseline rule ''` first). Each document resolves `group:` against its own roster. Not + "own replaces baseline": the language has no admitting effect, so AND lets a repository add restrictions and + never lift one, and no per-repo write (nor `PUT` of an empty document) can drop the host's protection; + exceptions are the operator's. Host-only like auth: not a settings section, same file on every host that + runs receive-pack. `GET …/policy` stays the repository's document; `GET …/policy/effective` lists the layers; + dry-run judges after the baseline. The overlapping-bypass check stays per document (`docs/POLICY.md`). + ## 5. Working rules - **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same diff --git a/README.md b/README.md index 1206f820..4bd0cd72 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Read the [design](docs/PACKFILE_URI_DESIGN.md) and | **git** | smart HTTP v0/v2: `ls-refs` with prefixes, fetch with filter/shallow/deepen/sideband-all, receive-pack (atomic, deletes, tags, push options, report-status-v2), `/` namespaces, sha1 and sha256 repositories. Upstream `git` does upload-pack/repack; walgit does receive-pack, the WAL and the plumbing. | | **LFS** | Batch API + basic transfer, objects in the bucket, optional read-through from an upstream LFS server for imported repositories. | | **web UI + API** | A React UI (tree, blob, commits, diffs, the WAL's own health page) on a read-mostly JSON API under `/{owner}/{repo}/api/*`; sha-addressed answers are immutable and cached everywhere; long answers stream progress as SSE. `repos.js` is a dependency-free SDK for pages, agents and scripts. | -| **policy** | Per-repository push rules (`policy.json`): protected refs, groups, fast-forward only, bypass lists. `docs/POLICY.md`. | +| **policy** | Per-repository push rules (`policy.json`): protected refs, groups, fast-forward only, bypass lists; optionally a host baseline every repository is judged against too. `docs/POLICY.md`. | | **settings** | Per-repository config (maintenance, compaction, upstream follow) published into the WAL with history. | | **events** | A small bridge tails the WAL and POSTs ref events to a webhook, exactly-once per (repo, seq, ref) with a durable cursor. `docs/EVENTS.md`. | | **maintenance** | Checkpoints, geometric compaction, connectivity audits and repairs — one loop that computes the desired state from (config, WAL) every pass and does one bounded unit of the most important missing work. Manual `compact --base` rebuilds the base on a host with sufficient disk. | diff --git a/crates/walgit-cli/src/config_cmd.rs b/crates/walgit-cli/src/config_cmd.rs index 7b74602e..c20fd814 100644 --- a/crates/walgit-cli/src/config_cmd.rs +++ b/crates/walgit-cli/src/config_cmd.rs @@ -27,6 +27,8 @@ pub async fn run(action: ConfigAction, cfg: &Arc) -> Result<()> { } let ignored = cfg.apply_env_report(vars.into_iter())?; cfg.validate()?; + // Startup parses the baseline too; `check` must fail where startup would. + walgit_server::policy::load_baseline(&cfg)?; for (k, why) in &ignored { eprintln!("ignored {k}: {why}"); } diff --git a/crates/walgit-config/src/lib.rs b/crates/walgit-config/src/lib.rs index e3cdaf15..aa158264 100644 --- a/crates/walgit-config/src/lib.rs +++ b/crates/walgit-config/src/lib.rs @@ -30,6 +30,7 @@ pub struct Config { pub lfs: LfsConfig, pub git: GitConfig, pub upstream: UpstreamConfig, + pub policy: PolicyConfig, /// Links to the systems around a repository (per repo via settings). #[serde(default)] pub telemetry: TelemetryConfig, @@ -341,6 +342,21 @@ pub struct CacheConfig { pub store_mount: Option, } +/// Host-side push policy (`docs/POLICY.md`). Host-only: not a settings section, so a +/// repository cannot name, replace or drop it. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct PolicyConfig { + /// A `policy.json` document every repository is evaluated against **in addition to** + /// its own `repos///policy.json` (baseline first, then the repository's; a + /// repository can add restrictions, never lift one). Read and parsed at startup by + /// the same parser and validator as `policy.json`; unreadable or invalid = the + /// process does not start. Held in memory: no store request. Every host that runs + /// receive-pack (fronts with a local fallback, the push broker) must carry the same + /// file, like `server.auth.tokens`. Unset = no baseline. + pub baseline: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields, default)] #[expect( @@ -1088,6 +1104,12 @@ impl Config { "packfile_uri.max_uris_per_fetch must be 1..=64" ); anyhow::ensure!(!self.store.bucket.is_empty(), "store.bucket must be set"); + if let Some(p) = &self.policy.baseline { + anyhow::ensure!( + !p.as_os_str().is_empty(), + "policy.baseline is empty (omit the key for no baseline)" + ); + } let t = &self.server.tls; match t.mode { TlsMode::Files => anyhow::ensure!( @@ -1549,6 +1571,30 @@ mod tests { ); } + #[test] + fn policy_baseline_is_a_host_only_path() { + let c = + Config::parse("[policy]\nbaseline = \"/etc/walgit/policy-baseline.json\"\n").unwrap(); + assert_eq!( + c.policy.baseline.as_deref(), + Some(std::path::Path::new("/etc/walgit/policy-baseline.json")) + ); + assert!(Config::parse("").unwrap().policy.baseline.is_none()); + // Parsing the document is the server's job (same parser as policy.json); the + // config only refuses what cannot name a file and typos in the section. + let e = Config::parse("[policy]\nbaseline = \"\"\n") + .unwrap_err() + .to_string(); + assert!(e.contains("policy.baseline"), "{e}"); + assert!(Config::parse("[policy]\ndefault = \"/x.json\"\n").is_err()); + // A repository cannot set, replace or clear the host's baseline. + let e = Config::default() + .with_settings("[policy]\nbaseline = \"/tmp/allow-all.json\"\n") + .unwrap_err() + .to_string(); + assert!(e.contains("[policy]"), "{e}"); + } + #[test] fn settings_merge_over_config_and_are_restricted() { let mut base = Config::default(); diff --git a/crates/walgit-server/src/lib.rs b/crates/walgit-server/src/lib.rs index 5f6c6154..743de687 100644 --- a/crates/walgit-server/src/lib.rs +++ b/crates/walgit-server/src/lib.rs @@ -102,6 +102,9 @@ pub struct AppState { pub follow: follow::FollowStatuses, /// In-process TLS (standalone, D39); `None` behind an edge (h2c). pub tls: Option>, + /// `[policy] baseline`, parsed at startup: judged before every repository's own + /// `policy.json` (`docs/POLICY.md`). In memory, so it costs a push no store request. + pub policy_baseline: Option, } impl AppState { @@ -114,6 +117,14 @@ impl AppState { let bridge = bridge::Bridge::new(&cfg, registry.clone()); let metrics_handle = metrics::install()?; let tls = tls::load(&cfg)?; + let policy_baseline = policy::load_baseline(&cfg)?; + if let Some(p) = &policy_baseline { + tracing::info!( + rules = p.rules.len(), + groups = p.groups.len(), + "push policy baseline loaded" + ); + } if let Some(t) = &tls { tracing::info!(fingerprint = %t.fingerprint, mode = ?cfg.server.tls.mode, "TLS terminated in-process"); } @@ -131,6 +142,7 @@ impl AppState { bridge, follow: follow::FollowStatuses::default(), tls, + policy_baseline, })) } } @@ -399,6 +411,9 @@ pub(crate) async fn dispatch_route( (&Method::DELETE, "") => admin::delete(st, route, &headers).await, // Admin routes reach here only through `/{o}/{r}/api[-browser]/…` (web::v1). (&Method::GET, "policy") => policy::http_get(st, route, &headers).await, + (&Method::GET, "policy/effective") => { + policy::http_get_effective(st, route, &headers).await + } (&Method::PUT, "policy") => { policy::http_put(st, route, &headers, body.take().unwrap()).await } diff --git a/crates/walgit-server/src/policy.rs b/crates/walgit-server/src/policy.rs index 33a3dd74..0ea37289 100644 --- a/crates/walgit-server/src/policy.rs +++ b/crates/walgit-server/src/policy.rs @@ -1,8 +1,11 @@ //! Per-repo push policy. Language: `docs/POLICY.md`. //! //! Stored at `repos///policy.json` (not on the WAL). Missing file -//! = empty rules = allow-all. Receive-pack evaluates after ingest so -//! force-push can use `merge-base --is-ancestor`. +//! = empty rules = allow-all. A host may add a **baseline** (`[policy] baseline`, +//! one more document of the same language, parsed at startup, held in memory): +//! every push is judged against the baseline and then the repository's own +//! document, each with its own roster, and must pass both. Receive-pack +//! evaluates after ingest so force-push can use `merge-base --is-ancestor`. use std::collections::{HashMap, HashSet}; @@ -487,20 +490,43 @@ impl Eval { } } +/// Whether a push needs force detection (`merge-base --is-ancestor` per update). +pub fn needs_force_check(baseline: Option<&RepoPolicy>, repo: &RepoPolicy) -> bool { + baseline.is_some_and(RepoPolicy::has_protect) || repo.has_protect() +} + +/// Judge `txn` against the host baseline (if any), then the repository's own +/// document. An update is allowed only if **both** allow it: `protect` is AND +/// across documents exactly as within one, so the order never changes a verdict, +/// only which rule is named (the first denying rule, baseline first). Each +/// document resolves `group:` against its own roster; a repository cannot join a +/// baseline group by defining one with the same name. +/// /// `is_force` is true when `new` is not a descendant of `old` (after ingest). /// Tag retargets are treated as force regardless. pub fn evaluate( - policy: &RepoPolicy, + baseline: Option<&RepoPolicy>, + repo: &RepoPolicy, principal: &str, txn: &RefTransaction, is_force: impl Fn(&RefUpdate) -> bool, ) -> Eval { - let groups: HashMap<&str, &Group> = - policy.groups.iter().map(|g| (g.name.as_str(), g)).collect(); + fn roster(p: &RepoPolicy) -> HashMap<&str, &Group> { + p.groups.iter().map(|g| (g.name.as_str(), g)).collect() + } + let layers: Vec<(&RepoPolicy, HashMap<&str, &Group>, &str)> = baseline + .map(|b| (b, roster(b), "baseline rule")) + .into_iter() + .chain(std::iter::once((repo, roster(repo), "rule"))) + .collect(); let mut per_ref = Vec::with_capacity(txn.updates.len()); let mut allowed = Vec::new(); for u in &txn.updates { - match deny_reason(policy, &groups, principal, u, &is_force) { + let denied = layers.iter().find_map(|(p, groups, what)| { + deny_reason(p, groups, principal, u, &is_force) + .map(|rule| format!("rejected by {what} '{rule}'")) + }); + match denied { None => { per_ref.push((u.name.clone(), Ok(()))); allowed.push(u.clone()); @@ -528,13 +554,14 @@ pub fn evaluate( } } -fn deny_reason( - policy: &RepoPolicy, +/// The name of the first rule of `policy` that denies `u`. +fn deny_reason<'p>( + policy: &'p RepoPolicy, groups: &HashMap<&str, &Group>, principal: &str, u: &RefUpdate, is_force: &impl Fn(&RefUpdate) -> bool, -) -> Option { +) -> Option<&'p str> { let op = classify(&u.old_oid, &u.new_oid); if op == RefOp::NoOp { return None; @@ -561,7 +588,7 @@ fn deny_reason( RefOp::NoOp => false, }; if hit { - return Some(format!("rejected by rule '{}'", rule.name)); + return Some(&rule.name); } } None @@ -618,14 +645,31 @@ pub fn parse_document(bytes: &[u8]) -> Result { } fn parse_bytes(bytes: &[u8]) -> Result { - let policy: RepoPolicy = serde_json::from_slice(bytes) - .map_err(|e| StoreError::InvalidArgument(format!("policy.json: {e}")))?; - policy - .validate() - .map_err(|e| StoreError::InvalidArgument(format!("policy.json: {e}")))?; + parse(bytes).map_err(|e| StoreError::InvalidArgument(format!("policy.json: {e}"))) +} + +/// The one parser + validator, for `policy.json` and the baseline alike. +fn parse(bytes: &[u8]) -> Result { + let policy: RepoPolicy = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + policy.validate()?; Ok(policy) } +/// `[policy] baseline`, read and parsed once at startup (`AppState::new`, +/// `walgit config check`). Unreadable or invalid is an error, never "no +/// baseline": a host that meant to protect every repository must not come up +/// allowing everything. +pub fn load_baseline(cfg: &walgit_config::Config) -> anyhow::Result> { + let Some(path) = &cfg.policy.baseline else { + return Ok(None); + }; + let bytes = std::fs::read(path) + .map_err(|e| anyhow::anyhow!("reading policy.baseline {}: {e}", path.display()))?; + let policy = + parse(&bytes).map_err(|e| anyhow::anyhow!("policy.baseline {}: {e}", path.display()))?; + Ok(Some(policy)) +} + pub async fn save(store: &DynStore, id: &RepoId, policy: &RepoPolicy) -> Result<(), StoreError> { policy.validate().map_err(StoreError::InvalidArgument)?; let key = store_key(id); @@ -673,6 +717,30 @@ pub async fn http_get( .into_response()) } +/// `GET …/policy/effective` → `{layers: [{source, policy}]}`: every document a +/// push is judged against, in evaluation order — `baseline` (only when the host +/// has one), then `repository` (the `GET …/policy` document; empty = none). +pub async fn http_get_effective( + st: &AppState, + route: &RepoRoute, + headers: &HeaderMap, +) -> Result { + let _ = st.auth.require_read(headers).await.map_err(auth_err)?; + ensure_repo(st, route).await?; + let repo = load(&st.store, &route.id).await.map_err(store_err)?; + let mut layers = Vec::new(); + if let Some(b) = &st.policy_baseline { + layers.push(serde_json::json!({"source": "baseline", "policy": b})); + } + layers.push(serde_json::json!({"source": "repository", "policy": repo})); + Ok(( + StatusCode::OK, + [(axum::http::header::CACHE_CONTROL, "no-store")], + axum::Json(serde_json::json!({ "layers": layers })), + ) + .into_response()) +} + pub async fn http_put( st: &AppState, route: &RepoRoute, @@ -815,7 +883,7 @@ mod tests { ], false, ); - let ev = evaluate(&p, "bob@example.com", &t, |_| true); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| true); assert!(ev.per_ref.iter().all(|(_, r)| r.is_ok())); assert_eq!(ev.publish.updates.len(), 2); } @@ -830,7 +898,7 @@ mod tests { ], false, ); - let ev = evaluate(&p, "bob@example.com", &t, |_| true); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| true); assert!( ev.per_ref[0] .1 @@ -842,7 +910,7 @@ mod tests { assert_eq!(ev.publish.updates.len(), 1); let del = txn(vec![upd("refs/heads/main", "aaa", "")], false); - let ev = evaluate(&p, "bob@example.com", &del, |_| false); + let ev = evaluate(None, &p, "bob@example.com", &del, |_| false); assert!(ev.per_ref[0].1.as_ref().unwrap_err().contains("lock-main")); assert!(!ev.any_allowed()); } @@ -851,7 +919,7 @@ mod tests { fn ff_update_allowed() { let p = lock_main(); let t = txn(vec![upd("refs/heads/main", "aaa", "bbb")], false); - let ev = evaluate(&p, "bob@example.com", &t, |_| false); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| false); assert!(ev.per_ref[0].1.is_ok()); } @@ -859,7 +927,7 @@ mod tests { fn group_bypass() { let p = lock_main(); let t = txn(vec![upd("refs/heads/main", "aaa", "bbb")], false); - let ev = evaluate(&p, "Alice@example.com", &t, |_| true); + let ev = evaluate(None, &p, "Alice@example.com", &t, |_| true); assert!(ev.per_ref[0].1.is_ok()); } @@ -867,7 +935,7 @@ mod tests { fn create_allowed_when_not_restricted() { let p = lock_main(); let t = txn(vec![upd("refs/heads/main", "", "aaa")], false); - let ev = evaluate(&p, "bob@example.com", &t, |_| true); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| true); assert!(ev.per_ref[0].1.is_ok()); } @@ -881,7 +949,7 @@ mod tests { ], true, ); - let ev = evaluate(&p, "bob@example.com", &t, |_| true); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| true); assert!(ev.any_denied()); assert!(!ev.any_allowed()); assert_eq!(ev.per_ref.len(), 2); @@ -900,7 +968,7 @@ mod tests { let p = parse_bytes(json.as_bytes()).unwrap(); let t = txn(vec![upd("refs/tags/v1", "aaa", "bbb")], false); // even if merge-base would say ff - let ev = evaluate(&p, "bob@example.com", &t, |_| false); + let ev = evaluate(None, &p, "bob@example.com", &t, |_| false); assert!(ev.per_ref[0].1.is_err()); } @@ -977,6 +1045,261 @@ mod tests { assert!(parse_bytes(json.as_bytes()).is_err()); } + /// The baseline example in `docs/POLICY.md`, verbatim (asserted below). + const BASELINE_EXAMPLE: &str = r#"{ + "version": 1, + "_comment": "members work in draft/** and explore/**; production is the deploy robot's; the rest of refs/heads and refs/tags is automation's", + "rules": [ + { + "name": "reserve-heads-and-tags", + "match": { + "refs": ["refs/heads/**", "refs/tags/**", "^refs/heads/draft/**", "^refs/heads/explore/**"] + }, + "effect": { "protect": { "bypass": ["svc:deploy", "svc:platform"] } } + }, + { + "name": "production-deploy-only", + "match": { "refs": ["refs/heads/production", "refs/heads/production/**"] }, + "effect": { "protect": { "bypass": ["svc:deploy"] } } + } + ] +}"#; + + fn baseline_example() -> RepoPolicy { + parse_bytes(BASELINE_EXAMPLE.as_bytes()).unwrap() + } + + /// One update per op on `name`, judged alone (non-atomic): `Ok` or the reason. + fn verdicts( + baseline: Option<&RepoPolicy>, + repo: &RepoPolicy, + who: &str, + name: &str, + ) -> Vec> { + // create, fast-forward update, force update, delete + let cases = [ + ("", "bbb", false), + ("aaa", "bbb", false), + ("aaa", "ccc", true), + ("aaa", "", false), + ]; + cases + .iter() + .map(|(old, new, force)| { + let t = txn(vec![upd(name, old, new)], false); + let ev = evaluate(baseline, repo, who, &t, |_| *force); + ev.per_ref[0].1.clone() + }) + .collect() + } + + fn all_ok(v: &[Result<(), String>]) -> bool { + v.iter().all(Result::is_ok) + } + + fn all_denied_by(v: &[Result<(), String>], reason: &str) -> bool { + v.iter() + .all(|r| r.as_ref().err().is_some_and(|e| e == reason)) + } + + #[test] + fn baseline_example_is_the_documented_one() { + let squash = |s: &str| s.split_whitespace().collect::(); + let doc = include_str!("../../../docs/POLICY.md"); + assert!( + squash(doc).contains(&squash(BASELINE_EXAMPLE)), + "docs/POLICY.md must carry BASELINE_EXAMPLE verbatim" + ); + } + + #[test] + fn baseline_example_reserves_everything_but_sandboxes() { + let b = baseline_example(); + let none = RepoPolicy::empty(); + let reserved = "rejected by baseline rule 'reserve-heads-and-tags'"; + let deploy_only = "rejected by baseline rule 'production-deploy-only'"; + let member = "bob@example.com"; + // Members: every op in the two sandboxes, nothing else under heads/tags. + for r in ["refs/heads/draft/x", "refs/heads/explore/a/b"] { + assert!(all_ok(&verdicts(Some(&b), &none, member, r)), "{r}"); + } + for r in [ + "refs/heads/main", + "refs/heads/draft", + "refs/heads/drafts/x", + "refs/tags/v1", + "refs/heads/production", + "refs/heads/production/eu", + ] { + // The first denying rule is named: the reservation, for production too. + assert!( + all_denied_by(&verdicts(Some(&b), &none, member, r), reserved), + "{r}" + ); + } + // Outside refs/heads and refs/tags the baseline says nothing. + assert!(all_ok(&verdicts( + Some(&b), + &none, + member, + "refs/notes/commits" + ))); + // The deploy robot: everything, production included. + for r in [ + "refs/heads/main", + "refs/tags/v1", + "refs/heads/production", + "refs/heads/production/eu", + "refs/heads/draft/x", + ] { + assert!(all_ok(&verdicts(Some(&b), &none, "svc:deploy", r)), "{r}"); + } + // The platform robot: everything but production (AND: it bypasses the + // reservation, not the production rule). + for r in ["refs/heads/main", "refs/tags/v1", "refs/heads/release/2"] { + assert!(all_ok(&verdicts(Some(&b), &none, "svc:platform", r)), "{r}"); + } + for r in ["refs/heads/production", "refs/heads/production/eu"] { + assert!( + all_denied_by(&verdicts(Some(&b), &none, "svc:platform", r), deploy_only), + "{r}" + ); + } + } + + #[test] + fn repository_policy_adds_to_the_baseline_never_lifts_it() { + let b = baseline_example(); + let reserved = "rejected by baseline rule 'reserve-heads-and-tags'"; + // An empty own document (what `PUT {}` or a fresh policy.json looks like) + // does not drop the host's protection. + let empty = parse_bytes(br#"{"version": 1, "rules": []}"#).unwrap(); + assert!(all_denied_by( + &verdicts(Some(&b), &empty, "bob@example.com", "refs/heads/main"), + reserved + )); + // A repository rule restricts further inside a sandbox; its denials name it + // as a repository rule. + let own = parse_bytes( + br#"{ + "version": 1, + "rules": [{ + "name": "freeze-draft-release", + "match": { "refs": ["refs/heads/draft/release"] }, + "effect": { "protect": { "restricts": ["update", "delete", "force-push"], "bypass": ["alice@example.com"] } } + }] + }"#, + ) + .unwrap(); + let v = verdicts( + Some(&b), + &own, + "bob@example.com", + "refs/heads/draft/release", + ); + assert!( + v[0].is_ok(), + "create is not restricted by the repository rule" + ); + assert!( + v[1..] + .iter() + .all(|r| r.as_ref().unwrap_err() == "rejected by rule 'freeze-draft-release'") + ); + assert!(all_ok(&verdicts( + Some(&b), + &own, + "alice@example.com", + "refs/heads/draft/release" + ))); + assert!(all_ok(&verdicts( + Some(&b), + &own, + "bob@example.com", + "refs/heads/draft/other" + ))); + // Both deny: the baseline rule is named (evaluation order), the verdict is the same. + let wide = parse_bytes( + br#"{"version": 1, "rules": [{"name": "lock-main", "match": {"refs": ["refs/heads/main"]}, "effect": {"protect": {}}}]}"#, + ) + .unwrap(); + assert!(all_denied_by( + &verdicts(Some(&b), &wide, "bob@example.com", "refs/heads/main"), + reserved + )); + // … and a robot the baseline admits is still held by the repository's rule. + assert!(all_denied_by( + &verdicts(Some(&b), &wide, "svc:deploy", "refs/heads/main"), + "rejected by rule 'lock-main'" + )); + // Without a baseline the repository document is the whole policy. + assert!(all_ok(&verdicts( + None, + &empty, + "bob@example.com", + "refs/heads/main" + ))); + } + + #[test] + fn rosters_do_not_cross_documents() { + let b = parse_bytes( + br#"{ + "version": 1, + "groups": [{ "name": "deployers", "members": ["svc:deploy"] }], + "rules": [{ + "name": "production", + "match": { "refs": ["refs/heads/production"] }, + "effect": { "protect": { "bypass": ["group:deployers"] } } + }] + }"#, + ) + .unwrap(); + // A repository that defines a group of the same name does not join it. + let own = parse_bytes( + br#"{"version": 1, "groups": [{ "name": "deployers", "members": ["alice@example.com"] }], "rules": []}"#, + ) + .unwrap(); + assert!(all_denied_by( + &verdicts(Some(&b), &own, "alice@example.com", "refs/heads/production"), + "rejected by baseline rule 'production'" + )); + assert!(all_ok(&verdicts( + Some(&b), + &own, + "svc:deploy", + "refs/heads/production" + ))); + } + + #[test] + fn baseline_uses_the_policy_json_validator() { + let dir = tempfile::tempdir().unwrap(); + let mut cfg = walgit_config::Config::default(); + assert!(load_baseline(&cfg).unwrap().is_none()); + let path = dir.path().join("baseline.json"); + cfg.policy.baseline = Some(path.clone()); + let e = load_baseline(&cfg).unwrap_err().to_string(); + assert!(e.contains("reading policy.baseline"), "{e}"); + std::fs::write(&path, BASELINE_EXAMPLE).unwrap(); + assert_eq!(load_baseline(&cfg).unwrap(), Some(baseline_example())); + // Every refusal of the policy.json parser refuses the baseline too. + for bad in [ + "not json", + r#"{"version": 2, "rules": []}"#, + r#"{"version": 1, "rules": [{"name": "x", "bypass_actrs": [], "match": {}, "effect": {"protect": {}}}]}"#, + r#"{"version": 1, "rules": [{"name": "x", "match": {}, "effect": {"protect": {"restricts": []}}}]}"#, + r#"{"version": 1, "rules": [ + {"name": "a", "match": {"refs": ["refs/heads/main"]}, "effect": {"protect": {"bypass": ["alice@example.com"]}}}, + {"name": "b", "match": {"refs": ["refs/heads/main"]}, "effect": {"protect": {"bypass": ["bob@example.com"]}}} + ]}"#, + ] { + std::fs::write(&path, bad).unwrap(); + let e = load_baseline(&cfg).unwrap_err().to_string(); + assert!(e.starts_with("policy.baseline "), "{bad}: {e}"); + } + } + #[test] fn unknown_envelope_key_ignored() { let json = r#"{ diff --git a/crates/walgit-server/src/settings.rs b/crates/walgit-server/src/settings.rs index 0c6da1e9..c02d2c67 100644 --- a/crates/walgit-server/src/settings.rs +++ b/crates/walgit-server/src/settings.rs @@ -369,7 +369,8 @@ pub async fn http_policy_validate( } /// `POST …/policy/dry-run?last=N` body = policy JSON (empty body = the saved -/// policy): evaluate it against the last N PUSH entries in the live log +/// policy): evaluate it, after the host baseline exactly as receive-pack +/// would, against the last N PUSH entries in the live log /// (principal + ref transaction as recorded) → per push, per ref: allowed or /// the denying rule. Force detection uses the local copy when objects are /// local, else "unknown" (treated as fast-forward). @@ -421,8 +422,9 @@ pub async fn http_policy_dry_run( .get("principal") .cloned() .unwrap_or_else(|| e.writer.clone()); + let baseline = st.policy_baseline.as_ref(); let mut forces = std::collections::HashSet::new(); - if policy.has_protect() { + if crate::policy::needs_force_check(baseline, &policy) { for u in &txn.updates { if crate::policy::classify(&u.old_oid, &u.new_oid) == crate::policy::RefOp::Update && matches!(local.is_ancestor(&u.old_oid, &u.new_oid).await, Ok(false)) @@ -431,7 +433,9 @@ pub async fn http_policy_dry_run( } } } - let ev = crate::policy::evaluate(&policy, &principal, &txn, |u| forces.contains(&u.name)); + let ev = crate::policy::evaluate(baseline, &policy, &principal, &txn, |u| { + forces.contains(&u.name) + }); let refs: Vec = ev .per_ref .iter() diff --git a/crates/walgit-server/src/smart.rs b/crates/walgit-server/src/smart.rs index ca89d49c..d0ea1d4a 100644 --- a/crates/walgit-server/src/smart.rs +++ b/crates/walgit-server/src/smart.rs @@ -1102,8 +1102,9 @@ async fn receive_pack_process( let policy = crate::policy::load(&st.store, &route_id) .await .map_err(|e| ApiError::Internal(format!("load policy: {e}")))?; + let baseline = st.policy_baseline.as_ref(); let mut forces = std::collections::HashSet::::new(); - if policy.has_protect() { + if crate::policy::needs_force_check(baseline, &policy) { for u in &txn.updates { if crate::policy::classify(&u.old_oid, &u.new_oid) == crate::policy::RefOp::Update { match local.is_ancestor(&u.old_oid, &u.new_oid).await { @@ -1115,7 +1116,9 @@ async fn receive_pack_process( } } } - let ev = crate::policy::evaluate(&policy, &principal.name, &txn, |u| forces.contains(&u.name)); + let ev = crate::policy::evaluate(baseline, &policy, &principal.name, &txn, |u| { + forces.contains(&u.name) + }); if !ev.any_allowed() { let report = build_report(&caps, unpack_result, &ev.per_ref).await; return Ok(report); diff --git a/crates/walgit-server/src/web/v1.rs b/crates/walgit-server/src/web/v1.rs index d297dec3..a51329b8 100644 --- a/crates/walgit-server/src/web/v1.rs +++ b/crates/walgit-server/src/web/v1.rs @@ -20,7 +20,7 @@ use axum::{ http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::Next, response::{Html, IntoResponse, Response}, - routing::{get, post}, + routing::get, }; use serde::Serialize; @@ -56,7 +56,10 @@ pub fn router(state: Arc) -> Router { &format!("{base}/policy"), get(repo_admin).put(repo_admin).delete(repo_admin), ) - .route(&format!("{base}/policy/{{sub}}"), post(repo_admin_sub)) + .route( + &format!("{base}/policy/{{sub}}"), + get(repo_admin_sub).post(repo_admin_sub), + ) .route( &format!("{base}/settings"), get(repo_admin).put(repo_admin).delete(repo_admin), @@ -242,6 +245,7 @@ async fn discovery(State(st): State>, headers: HeaderMap) -> Respo "GET /{owner}/{repo}/api/ops", "POST /{owner}/{repo}/api/ops/{op}", "GET|PUT|DELETE /{owner}/{repo}/api/policy", + "GET /{owner}/{repo}/api/policy/effective", "POST /{owner}/{repo}/api/policy/validate | dry-run?last=N", "GET|PUT|DELETE /{owner}/{repo}/api/settings", "GET /{owner}/{repo}/api/settings/effective | history | describe", @@ -366,7 +370,8 @@ async fn repo_summary( .await } -/// `GET|POST /{o}/{r}/api[-browser]/settings/{sub}` (effective | history | validate) and `POST …/policy/{sub}`. +/// `GET|POST /{o}/{r}/api[-browser]/settings/{sub}` (effective | history | validate) and +/// `GET|POST …/policy/{sub}` (effective | validate | dry-run). async fn repo_admin_sub( State(st): State>, Path((owner, name, _sub)): Path<(String, String, String)>, diff --git a/crates/walgit-server/tests/policy.rs b/crates/walgit-server/tests/policy.rs index 72f2c875..58db3a0c 100644 --- a/crates/walgit-server/tests/policy.rs +++ b/crates/walgit-server/tests/policy.rs @@ -137,3 +137,263 @@ async fn protected_main_rejects_force_and_delete() -> TestResult { git_in(&src, &["push", "--force", "origin", "other:main"])?; Ok(()) } + +// ---- [policy] baseline --------------------------------------------------------------- + +/// The `docs/POLICY.md` baseline example: members in draft/** and explore/**, +/// production for `svc:deploy`, the rest of heads/tags for the two robots. +const BASELINE: &str = r#"{ + "version": 1, + "rules": [ + { + "name": "reserve-heads-and-tags", + "match": { "refs": ["refs/heads/**", "refs/tags/**", "^refs/heads/draft/**", "^refs/heads/explore/**"] }, + "effect": { "protect": { "bypass": ["svc:deploy", "svc:platform"] } } + }, + { + "name": "production-deploy-only", + "match": { "refs": ["refs/heads/production", "refs/heads/production/**"] }, + "effect": { "protect": { "bypass": ["svc:deploy"] } } + } + ] +}"#; + +/// A repository's own policy.json: nothing, then one restriction inside a sandbox. +const EMPTY: &str = r#"{"version": 1, "rules": []}"#; +const OWN: &str = r#"{"version": 1, "rules": [{"name": "freeze-drafts", "match": {"refs": ["refs/heads/draft/**"]}, "effect": {"protect": {"restricts": ["delete"]}}}]}"#; + +/// Token mode: one member, two robots, one admin (who is also the only one to +/// write the repository's own policy.json). +fn principals(c: &mut walgit_config::Config) { + c.server.auth.mode = walgit_config::AuthMode::Token; + c.server.auth.anonymous_read = false; + c.server.auth.tokens = [ + ("bob@example.com", "member", false), + ("svc:deploy", "deploy", false), + ("svc:platform", "platform", false), + ("root", "admin", true), + ] + .into_iter() + .map(|(principal, token, admin)| walgit_config::StaticToken { + principal: principal.into(), + token: token.into(), + token_env: None, + write: true, + admin, + }) + .collect(); +} + +/// `git push` as the principal behind `token`: `Ok(())` or the stderr of the refusal. +fn push_as( + src: &std::path::Path, + token: &str, + args: &[&str], +) -> anyhow::Result> { + let header = format!("http.extraHeader=Authorization: Bearer {token}"); + let out = Command::new("git") + .current_dir(src) + .args(["-c", &header, "push"]) + .args(args) + .output()?; + Ok(if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).into_owned()) + }) +} + +async fn api( + server: &Server, + method: reqwest::Method, + path: &str, + body: Option<&str>, +) -> anyhow::Result<(u16, serde_json::Value)> { + let mut r = reqwest::Client::new() + .request(method, format!("{}{path}", server.base_url)) + .header("Authorization", "Bearer admin"); + if let Some(b) = body { + r = r + .header("content-type", "application/json") + .body(b.to_string()); + } + let resp = r.send().await?; + let status = resp.status().as_u16(); + let text = resp.text().await?; + Ok(( + status, + serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)), + )) +} + +/// A repository without its own policy.json is judged by the host baseline on +/// receive-pack; one with its own is judged by both (it can add, never lift); the +/// dry-run judges exactly like receive-pack; `…/policy` stays the repository's +/// document and `…/policy/effective` shows both layers. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn baseline_governs_receive_pack_settings_and_effective_view() -> TestResult { + let dir = tempfile::tempdir()?; + let baseline = dir.path().join("policy-baseline.json"); + std::fs::write(&baseline, BASELINE)?; + + // A host without a baseline records a member's push to main: the dry-run on + // the guarded host below must deny it the way its receive-pack would. + let plain = Server::start_with_tweak(principals).await?; + let (st, body) = api(&plain, reqwest::Method::PUT, "/t/r/api", None).await?; + assert!(st == 201 || st == 200, "{st} {body}"); + let src = TestRepo::synthetic(2, 1)?; + git_in( + &src, + &["remote", "add", "origin", &plain.repo_url("t", "r")], + )?; + push_as(&src, "member", &["origin", "main"])?.map_err(anyhow::Error::msg)?; + + let guarded = plain + .start_sibling_with(|c| { + principals(c); + c.policy.baseline = Some(baseline.clone()); + }) + .await?; + git_in( + &src, + &["remote", "set-url", "origin", &guarded.repo_url("t", "r")], + )?; + git_in(&src, &["commit", "--allow-empty", "-m", "next"])?; + + // No policy.json: the baseline alone. + let reserved = "rejected by baseline rule 'reserve-heads-and-tags'"; + let e = push_as(&src, "member", &["origin", "main"])?.unwrap_err(); + assert!(e.contains(reserved), "member moved main: {e}"); + push_as(&src, "member", &["origin", "main:refs/heads/draft/x"])?.map_err(anyhow::Error::msg)?; + push_as(&src, "member", &["origin", ":refs/heads/draft/x"])?.map_err(anyhow::Error::msg)?; + let e = push_as(&src, "member", &["origin", "main:refs/heads/production"])?.unwrap_err(); + assert!(e.contains(reserved), "{e}"); + let e = push_as(&src, "platform", &["origin", "main:refs/heads/production"])?.unwrap_err(); + assert!( + e.contains("rejected by baseline rule 'production-deploy-only'"), + "{e}" + ); + push_as(&src, "deploy", &["origin", "main:refs/heads/production"])? + .map_err(anyhow::Error::msg)?; + push_as(&src, "platform", &["origin", "main"])?.map_err(anyhow::Error::msg)?; + + // An own policy.json adds to the baseline: an empty-rules document drops + // nothing, a rule of its own restricts inside the sandbox. + let (st, body) = api( + &guarded, + reqwest::Method::PUT, + "/t/r/api/policy", + Some(EMPTY), + ) + .await?; + assert_eq!(st, 204, "{body}"); + git_in(&src, &["commit", "--allow-empty", "-m", "again"])?; + let e = push_as(&src, "member", &["origin", "main"])?.unwrap_err(); + assert!( + e.contains(reserved), + "an empty policy.json dropped the baseline: {e}" + ); + let (st, body) = api(&guarded, reqwest::Method::PUT, "/t/r/api/policy", Some(OWN)).await?; + assert_eq!(st, 204, "{body}"); + let e = push_as(&src, "member", &["origin", "main"])?.unwrap_err(); + assert!(e.contains(reserved), "the baseline still holds main: {e}"); + push_as(&src, "member", &["origin", "main:refs/heads/draft/y"])?.map_err(anyhow::Error::msg)?; + let e = push_as(&src, "member", &["origin", ":refs/heads/draft/y"])?.unwrap_err(); + assert!(e.contains("rejected by rule 'freeze-drafts'"), "{e}"); + + // `…/policy` is the repository's own document, unchanged in shape. + let (st, own) = api(&guarded, reqwest::Method::GET, "/t/r/api/policy", None).await?; + assert_eq!(st, 200); + assert_eq!(own["rules"].as_array().unwrap().len(), 1); + assert_eq!(own["rules"][0]["name"], "freeze-drafts"); + assert!( + own.get("layers").is_none() && own.get("baseline").is_none(), + "{own}" + ); + // `…/policy/effective`: both layers, in evaluation order. + let (st, eff) = api( + &guarded, + reqwest::Method::GET, + "/t/r/api/policy/effective", + None, + ) + .await?; + assert_eq!(st, 200, "{eff}"); + let layers = eff["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 2, "{eff}"); + assert_eq!(layers[0]["source"], "baseline"); + assert_eq!( + layers[0]["policy"]["rules"][0]["name"], + "reserve-heads-and-tags" + ); + assert_eq!(layers[1]["source"], "repository"); + assert_eq!(layers[1]["policy"], own); + let (_, eff) = api( + &plain, + reqwest::Method::GET, + "/t/r/api/policy/effective", + None, + ) + .await?; + let layers = eff["layers"].as_array().unwrap(); + assert_eq!(layers.len(), 1, "no baseline on this host: {eff}"); + assert_eq!(layers[0]["source"], "repository"); + + // Settings dry-run: the saved policy after the baseline, as receive-pack judges. + // The member's first push to main (recorded by the plain host) is denied here … + let (st, dr) = api( + &guarded, + reqwest::Method::POST, + "/t/r/api/policy/dry-run?last=50", + Some(""), + ) + .await?; + assert_eq!(st, 200, "{dr}"); + let first = dr["results"].as_array().unwrap().last().unwrap().clone(); + assert_eq!(first["principal"], "bob@example.com", "{dr}"); + assert_eq!(first["refs"][0]["name"], "refs/heads/main"); + assert_eq!(first["refs"][0]["ok"], false, "{first}"); + assert_eq!(first["refs"][0]["reason"], reserved); + // … and allowed where there is none. + let (_, dr) = api( + &plain, + reqwest::Method::POST, + "/t/r/api/policy/dry-run?last=50", + Some(""), + ) + .await?; + let first = dr["results"].as_array().unwrap().last().unwrap().clone(); + assert_eq!(first["refs"][0]["ok"], true, "{first}"); + Ok(()) +} + +/// Fail closed: a baseline that cannot be read or does not pass the policy.json +/// validator stops startup instead of serving allow-all. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn invalid_baseline_stops_startup() -> TestResult { + let dir = tempfile::tempdir()?; + let missing = dir.path().join("missing.json"); + let garbage = dir.path().join("garbage.json"); + std::fs::write(&garbage, "{ not json")?; + let lockout = dir.path().join("lockout.json"); + std::fs::write( + &lockout, + r#"{"version": 1, "rules": [ + {"name": "a", "match": {"refs": ["refs/heads/main"]}, "effect": {"protect": {"bypass": ["alice@example.com"]}}}, + {"name": "b", "match": {"refs": ["refs/heads/main"]}, "effect": {"protect": {"bypass": ["bob@example.com"]}}} + ]}"#, + )?; + for (path, want) in [ + (&missing, "reading policy.baseline"), + (&garbage, "policy.baseline"), + (&lockout, "disjoint bypass lists"), + ] { + let p = path.clone(); + let Err(e) = Server::start_with_tweak(move |c| c.policy.baseline = Some(p)).await else { + panic!("started with baseline {}", path.display()); + }; + let e = format!("{e:#}"); + assert!(e.contains(want), "{}: {e}", path.display()); + } + Ok(()) +} diff --git a/docs/POLICY.md b/docs/POLICY.md index dc622bdd..8d2956e7 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -7,7 +7,10 @@ the operating decision is `AGENTS.md §3 D16`. Read when touching receive-pack a Normative. Implementation: `crates/walgit-server/src/policy.rs`. On-store object: `repos///policy.json` (not on the WAL, not in `walgit.toml`). `GET`/`PUT`/`DELETE /{owner}/{repo}/policy` and -`walgit repo policy get|set|clear`. +`walgit repo policy get|set|clear`. A host may add one **baseline** document +(`[policy] baseline` in `walgit.toml`) that every repository is judged against +as well — [Host baseline](#host-baseline). `GET /{owner}/{repo}/api/policy/effective` +shows both. This file is a **small rule language whose combination law is fixed**, then serialized. It is not a JSON bag that mixes ACL, protection, and size ceilings @@ -30,7 +33,8 @@ Leave out anything receive-pack cannot enforce: required reviews, required CI, file just lies. Missing file / empty `rules` = allow-all (anyone with write may move any -ref). That is the only implicit default. +ref), unless the host has a baseline, which applies regardless. That is the +only implicit default. ## Envelope @@ -216,15 +220,87 @@ That is enough for a real host: lock the trunk, reserve bot namespaces, keep tags still. History / size rules can be added later without changing the envelope; they will not change a verdict until this document says they do. +## Host baseline + +`[policy] baseline = ""` names a document in this language, on the +host's disk, that every repository is judged against **as well as** its own +`policy.json`. It exists so a deployment can protect a ref in every repository +from the first push, without a per-repo write that races that push. + +- **Both apply.** A push is judged against the baseline, then against the + repository's document; an update passes only if it passes both. That is the + `protect` law (AND) carried across documents, so the order never changes a + verdict. It only decides which rule is named: the first denying rule, + baseline first — `rejected by baseline rule ''`, or + `rejected by rule ''` for the repository's own. +- **Not "own replaces baseline".** Repositories get a `policy.json` for local + reasons (lock their trunk). Under replacement that write, or a `PUT` of an + empty document, would silently drop the host's protection. There is no + admitting effect in the language — `protect` only restricts — so under AND a + repository can add restrictions and never lift one. An exception is the + operator's: change the baseline or its bypass list. +- **Two namespaces.** `group:` resolves against the roster of the document + that references it; a repository defining `group:deployers` does not join + the baseline's. Rule names are unique per document. +- **Loaded once.** Read at startup with the same parser and validator as + `policy.json`; unreadable or invalid stops the process (and fails + `walgit config check`). Never "no baseline". Held in memory: no store + request on a push. It is host config, like `server.auth.tokens`: every host + that runs receive-pack (a front's local fallback, the push broker) must carry + the same file. A repository's settings cannot set it. +- **Lockout across documents is not refused.** The overlapping-bypass check + below runs per document. A repository rule whose bypass list is disjoint from + an overlapping baseline rule's makes that ref unpushable for everyone; a `PUT` + does not catch it (the baseline can change without the repository's document + changing, and the conservative overlap test would refuse most repository + rules under a baseline over `refs/heads/**`). `POST …/policy/dry-run` judges + a candidate after the baseline, exactly as receive-pack does. + +A baseline that gives members two sandboxes, production to the deploy robot, +and the rest of `refs/heads` and `refs/tags` to the robots: + +```json +{ + "version": 1, + "_comment": "members work in draft/** and explore/**; production is the deploy robot's; the rest of refs/heads and refs/tags is automation's", + "rules": [ + { + "name": "reserve-heads-and-tags", + "match": { + "refs": ["refs/heads/**", "refs/tags/**", "^refs/heads/draft/**", "^refs/heads/explore/**"] + }, + "effect": { "protect": { "bypass": ["svc:deploy", "svc:platform"] } } + }, + { + "name": "production-deploy-only", + "match": { "refs": ["refs/heads/production", "refs/heads/production/**"] }, + "effect": { "protect": { "bypass": ["svc:deploy"] } } + } + ] +} +``` + +Anyone with write may create, update, force and delete under `draft/**` and +`explore/**`: no rule matches there. Everything else under `refs/heads` and +`refs/tags` — `refs/heads/draft` itself included — is reserved. Production +matches both rules; AND admits only a principal that bypasses both, so +`svc:platform` is held by the second. `refs/heads/production` is listed next to +`refs/heads/production/**` because `**` after a `/` needs that `/`. The robots +are spelled out rather than grouped: the lockout check compares bypass entries +as written, and `group:robots` against `svc:deploy` would read as disjoint. +Refs outside `refs/heads` and `refs/tags` (notes, review refs) are untouched. + ## Load rules - Fail closed on a half-wired file. A document that does not parse is a - `400` on `PUT` and a reject on the next push (not “skip policy”). + `400` on `PUT` and a reject on the next push (not “skip policy”); a + baseline that does not parse stops startup. - Last-good backup is **not implemented**. A corrupt object currently fails the push. Do not pretend otherwise. - Pin the shipped file in tests. Load real JSON, drive `(principal, ref, op)` through the real matcher, assert allow / deny. -- Explain the verdict. The `ng` line is `rejected by rule ''`. +- Explain the verdict. The `ng` line is `rejected by rule ''` + (`rejected by baseline rule ''` when the baseline denied). - Overlapping-bypass lockout: if two `protect` rules can match the same ref and the same op, and both have non-empty bypass lists whose intersection is empty, load fails. AND would make the intended bot unable to land. diff --git a/docs/ROUNDTRIPS.md b/docs/ROUNDTRIPS.md index 447af901..ca412401 100644 --- a/docs/ROUNDTRIPS.md +++ b/docs/ROUNDTRIPS.md @@ -52,6 +52,7 @@ right shape. This document is the thinking tool; apply it to every protocol chan | Validated remote index admission | warm valid index: 0 store requests; absent/corrupt index: index GET (HEAD first only for old descriptors with unknown size), at most four indexes downloading concurrently | unchanged healthy store depth; corruption now triggers repair instead of admitting bad evidence. Checksums, lock, mmap and cleanup are local work; no LIST. Per-index checksum CPU cost occurs when constructing a new remote inventory, not per object lookup. | `index_cache.rs`, `remote.rs` | | Final replacement evidence | coherent refs-level manifest capture → verified current index mappings → conservation/current-tip scan → existing log claim/CAS | warm indexes add 0 store requests; cold/bad evidence adds index GETs (HEAD only for unknown sizes), four concurrent downloads. O(refs) local capture and streaming index-union scan per CAS attempt; no pack download or LIST. This cost is not zero-read or constant-time seal certification. | `classification.rs`, `closure.rs`, `index_cache.rs` | | Lost CAS response resolution | fresh manifest GET; only if the exact segment descriptor is listed, GET and compare the claimed log bytes | normally +1 GET on this failure path compared with key/sequence-only resolution; no added successful-push requests. Missing/folded evidence remains unknown. Per-attempt nonce and actual frame sizes are computed locally. | `publish.rs::cas_landed` | +| Push policy (receive-pack, dry-run) | `policy.json` GET (a 404 is free) after ingest; the host baseline (`[policy] baseline`) is parsed at startup and held in memory | 1, unchanged by a baseline: 0 extra | `policy.rs::load`, `smart.rs` | | Settings publish (D24) | refs sync (conditional GET) → log slot PUT → manifest CAS; readers pay nothing extra (settings ride inline on the manifest) | 3 rounds; read: 0 | `publish.rs::publish_settings_impl` | | Lease acquire | 1 GET → 1 CAS put (or 1 Create when absent) | 2 | `coord.rs::try_acquire` | | Publish, local commit (2026-08-23) | unchanged in round trips: after the manifest CAS the ref txns are applied to the local copy **before** the new manifest version is advertised, both under `sync_mutex` (the refs phase of every sync); the reverse order let a reader cache the old refs under the new version, and without the lock a concurrent sync replayed the same entry (two `update-ref`, a lock collision). A landed CAS is answered `ok` whatever the local apply does — the next sync replays (one conditional GET that then returns 200, no extra write). | 0 extra | `publish.rs::process_batch` | diff --git a/walgit.example.toml b/walgit.example.toml index 79584aa9..d1950b07 100644 --- a/walgit.example.toml +++ b/walgit.example.toml @@ -178,6 +178,15 @@ max_object_bytes = "16GiB" # follow = ["refs/heads/main"] # refs kept equal to upstream's by the maintaining host: fetched every # maintenance.follow_interval, published through the WAL as a push; fast-forward only +# Push policy every repository is judged against IN ADDITION to its own policy.json (docs/POLICY.md, +# "Host baseline"): baseline first, then the repository's; an update must pass both, so a repository can +# add restrictions but never lift one. Same language, parser and validator as policy.json; read once at +# startup — unreadable or invalid stops the process. Host-only (not a repository setting); every host that +# runs receive-pack must carry the same file. Unset = no baseline (a repository without policy.json is allow-all). +[policy] +# baseline = "/etc/walgit/policy-baseline.json" # e.g. docs/POLICY.md's example: members in draft/** and explore/**, + # production for svc:deploy, the rest of refs/heads + refs/tags for the robots + [git] binary = "git" # path to upstream git binary (>= 2.47 recommended: pack.writeReverseIndex) upload_pack_engine = "auto" # "auto" = stock git when packs are local/mount-linked, gix only for remote-served bases | "git" | "gix" diff --git a/web/API.md b/web/API.md index 147dee43..13696209 100644 --- a/web/API.md +++ b/web/API.md @@ -262,7 +262,10 @@ Ref-level summary: head (`null` when unborn), O(1) ref counts from the ref index, URLs. `404` for an unknown repo. Cache: SWR + `ETag: ""`. `PUT` creates the repository (write permission; `201`/`200`), `DELETE` removes it (admin permission) — the same handlers as `PUT|DELETE /{owner}/{repo}`. -`GET|PUT|DELETE …/policy` is the push policy document (`docs/POLICY.md`). +`GET|PUT|DELETE …/policy` is the repository's own push policy document (`docs/POLICY.md`; `GET` of none = the +empty document). `GET …/policy/effective` → `{layers: [{source, policy}]}`, every document a push is judged +against in evaluation order: `{"source": "baseline", …}` only when the host has a `[policy] baseline`, then +`{"source": "repository", …}` (the `GET …/policy` document). An update must pass every layer. `no-store`. `GET|PUT|DELETE /{o}/{r}/api/settings` (D24, 2026-08-21) is the repository's **settings in the WAL**: a TOML document restricted to `[refs]`, `[packfile_uri]`, `[maintenance]`, `[packs]` and `[upstream]`, merged over the @@ -285,7 +288,8 @@ detail,upstream:{ref:oid},ours:{ref:oid}}|null} (D33; last_round = this instance fields:[{key,value,host_value,source: host|setting}], head_seq}`; `POST …/settings/validate` (body TOML) → the same shape for the *would-be* effective config with `ok: true`, or `{ok: false, errors[]}` (nothing published); `POST …/policy/validate` (body policy JSON) → `{ok, errors[], rules, groups, protect}`; `POST …/policy/dry-run?last=N` -(body policy JSON, empty = the saved policy) → the policy evaluated against the last N PUSH entries of the live log +(body policy JSON, empty = the saved policy) → the policy evaluated, after the host baseline as receive-pack would +(a baseline denial reads `rejected by baseline rule ''`), against the last N PUSH entries of the live log (`{pushes, allowed, denied, results:[{seq,at,principal,atomic,refs:[{name,ok,reason,force}]}]}`; force = non-ancestor update when objects are local). SDK: `repo.settings.{get,put,delete,effective,history,describe,validate}`, `repo.policy.{validate,dryRun}`.