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}`.