23a19b89
Delegates write refs/collab/* and nothing else
a73x 2026-08-18 17:17
Commit message
src/server/governance/hook.rs
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,9 @@ use super::{creator_of, load, validate_settings_tree, GovernanceState, SETTINGS_ | |||
| 26 | /// back. Named rather than inherited so that a hook running outside the server | 26 | /// back. Named rather than inherited so that a hook running outside the server |
| 27 | /// — by hand, say — fails closed instead of guessing. | 27 | /// — by hand, say — fails closed instead of guessing. |
| 28 | pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL"; | 28 | pub const ENV_PRINCIPAL: &str = "GIT_COLLAB_PRINCIPAL"; |
| 29 | /// Set only when the pushing session is a delegate certificate; its value is | ||
| 30 | /// the cert's key ID. Presence is what puts the collab-refs ceiling in force. | ||
| 31 | pub const ENV_DELEGATE: &str = "GIT_COLLAB_DELEGATE"; | ||
| 29 | pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR"; | 32 | pub const ENV_REPOS_DIR: &str = "GIT_COLLAB_REPOS_DIR"; |
| 30 | pub const ENV_REPO: &str = "GIT_COLLAB_REPO"; | 33 | pub const ENV_REPO: &str = "GIT_COLLAB_REPO"; |
| 31 | pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH"; | 34 | pub const ENV_REPO_PATH: &str = "GIT_COLLAB_REPO_PATH"; |
| @@ -135,6 +138,18 @@ pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> { | |||
| 135 | if principal.is_empty() { | 138 | if principal.is_empty() { |
| 136 | return Err("no authenticated principal on this push".to_string()); | 139 | return Err("no authenticated principal on this push".to_string()); |
| 137 | } | 140 | } |
| 141 | // The delegate ceiling. Hard-coded rather than configured: no line in | ||
| 142 | // access.conf can widen what a certificate may write. | ||
| 143 | let delegate = std::env::var(ENV_DELEGATE).ok().filter(|v| !v.is_empty()); | ||
| 144 | if let Some(key_id) = &delegate { | ||
| 145 | if !refname.starts_with("refs/collab/") { | ||
| 146 | return Err(format!( | ||
| 147 | "delegate {key_id} of {principal} may only write refs/collab/*, \ | ||
| 148 | not {refname}" | ||
| 149 | )); | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 138 | let creator = creator_of(&repo_path); | 153 | let creator = creator_of(&repo_path); |
| 139 | let subject = Subject::with_creator(&principal, creator.as_deref()); | 154 | let subject = Subject::with_creator(&principal, creator.as_deref()); |
| 140 | let access = required_access(&repo, old, new); | 155 | let access = required_access(&repo, old, new); |
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -29,9 +29,6 @@ enum Regime { | |||
| 29 | name: String, | 29 | name: String, |
| 30 | /// `Some(key_id)` when this session is a certificate, acting for | 30 | /// `Some(key_id)` when this session is a certificate, acting for |
| 31 | /// `name` under the hard-coded collab-refs ceiling. | 31 | /// `name` under the hard-coded collab-refs ceiling. |
| 32 | /// | ||
| 33 | /// Not read yet: the ceiling itself lands in a follow-up task. | ||
| 34 | #[allow(dead_code)] | ||
| 35 | delegate: Option<String>, | 32 | delegate: Option<String>, |
| 36 | }, | 33 | }, |
| 37 | /// Either the settings repository could not be read, or this connection's | 34 | /// Either the settings repository could not be read, or this connection's |
| @@ -195,8 +192,11 @@ impl SshHandler { | |||
| 195 | repo_path.to_string_lossy().into_owned(), | 192 | repo_path.to_string_lossy().into_owned(), |
| 196 | ), | 193 | ), |
| 197 | ]; | 194 | ]; |
| 198 | if let Regime::Governed { name, .. } = regime { | 195 | if let Regime::Governed { name, delegate, .. } = regime { |
| 199 | env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone())); | 196 | env.push((governance::hook::ENV_PRINCIPAL.to_string(), name.clone())); |
| 197 | if let Some(key_id) = delegate { | ||
| 198 | env.push((governance::hook::ENV_DELEGATE.to_string(), key_id.clone())); | ||
| 199 | } | ||
| 200 | } | 200 | } |
| 201 | Ok(env) | 201 | Ok(env) |
| 202 | } | 202 | } |
| @@ -245,6 +245,18 @@ impl SshHandler { | |||
| 245 | ReleaseCmd::List { .. } => Access::Read, | 245 | ReleaseCmd::List { .. } => Access::Read, |
| 246 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, | 246 | ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, |
| 247 | }; | 247 | }; |
| 248 | // Artifacts are not collab refs. A delegate may list what its person | ||
| 249 | // may see; publishing and deleting are outside the ceiling. | ||
| 250 | if let Regime::Governed { | ||
| 251 | delegate: Some(key_id), | ||
| 252 | .. | ||
| 253 | } = regime | ||
| 254 | { | ||
| 255 | if needed != Access::Read { | ||
| 256 | warn!("Rejected release command from delegate {key_id}"); | ||
| 257 | return reply_and_close(session, channel, NOT_FOUND, 1); | ||
| 258 | } | ||
| 259 | } | ||
| 248 | let authorized = match regime { | 260 | let authorized = match regime { |
| 249 | Regime::Closed => false, | 261 | Regime::Closed => false, |
| 250 | Regime::Ungoverned => match needed { | 262 | Regime::Ungoverned => match needed { |
| @@ -807,14 +819,17 @@ impl Handler for SshHandler { | |||
| 807 | // allocator, so under governance it is a permission of its own: | 819 | // allocator, so under governance it is a permission of its own: |
| 808 | // `C` on a pattern the requested name matches. | 820 | // `C` on a pattern the requested name matches. |
| 809 | if let Regime::Governed { | 821 | if let Regime::Governed { |
| 810 | governance, name, .. | 822 | governance, |
| 823 | name, | ||
| 824 | delegate, | ||
| 811 | } = ®ime | 825 | } = ®ime |
| 812 | { | 826 | { |
| 813 | let allowed = repo_key.as_deref().is_some_and(|key| { | 827 | let allowed = delegate.is_none() |
| 814 | governance | 828 | && repo_key.as_deref().is_some_and(|key| { |
| 815 | .conf | 829 | governance |
| 816 | .allows_repo(key, &Subject::new(name), Access::Create) | 830 | .conf |
| 817 | }); | 831 | .allows_repo(key, &Subject::new(name), Access::Create) |
| 832 | }); | ||
| 818 | if !allowed { | 833 | if !allowed { |
| 819 | warn!( | 834 | warn!( |
| 820 | "Rejected exec request: principal {} may not create {:?}", | 835 | "Rejected exec request: principal {} may not create {:?}", |
tests/delegate_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -94,3 +94,110 @@ fn a_ca_enrolled_for_another_name_is_rejected() { | |||
| 94 | "a CA enrolled for bob minted a working delegate of alex" | 94 | "a CA enrolled for bob minted a working delegate of alex" |
| 95 | ); | 95 | ); |
| 96 | } | 96 | } |
| 97 | |||
| 98 | /// The ceiling: a delegate writes collab refs with the person's authority — | ||
| 99 | /// and cannot move a branch the person holds RW+ on. | ||
| 100 | #[test] | ||
| 101 | fn a_delegate_writes_collab_refs_and_may_not_write_branches() { | ||
| 102 | let harness = ServerHarness::new("delegate-ceiling"); | ||
| 103 | harness.push_head(); | ||
| 104 | harness.bootstrap_settings_with_cas( | ||
| 105 | &access_conf(harness.repo_name()), | ||
| 106 | &[("alex.pub", "alex")], | ||
| 107 | &[("mint/alex.pub", "mint")], | ||
| 108 | ); | ||
| 109 | |||
| 110 | let agent_key = harness.named_key("agent-key"); | ||
| 111 | let ca = harness.delegate_ca("mint"); | ||
| 112 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 113 | |||
| 114 | harness.work_repo().issue_open("Filed by a delegate"); | ||
| 115 | let push = harness.ssh_push_from_cert( | ||
| 116 | harness.work_repo().dir.path(), | ||
| 117 | &agent_key, | ||
| 118 | &cert, | ||
| 119 | harness.repo_name(), | ||
| 120 | "refs/collab/*:refs/collab/*", | ||
| 121 | ); | ||
| 122 | assert!( | ||
| 123 | push.status.success(), | ||
| 124 | "delegate collab push failed: {}", | ||
| 125 | stderr(&push) | ||
| 126 | ); | ||
| 127 | |||
| 128 | harness | ||
| 129 | .work_repo() | ||
| 130 | .commit_file("d.txt", "delegate", "delegate commit"); | ||
| 131 | let push = harness.ssh_push_from_cert( | ||
| 132 | harness.work_repo().dir.path(), | ||
| 133 | &agent_key, | ||
| 134 | &cert, | ||
| 135 | harness.repo_name(), | ||
| 136 | "main:main", | ||
| 137 | ); | ||
| 138 | assert!( | ||
| 139 | !push.status.success(), | ||
| 140 | "a delegate moved a branch; alex holds RW+ but the cert must not inherit it" | ||
| 141 | ); | ||
| 142 | assert!( | ||
| 143 | stderr(&push).contains("refs/collab"), | ||
| 144 | "the refusal should name the ceiling, got: {}", | ||
| 145 | stderr(&push) | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | /// The person's own key is untouched by the ceiling. | ||
| 150 | #[test] | ||
| 151 | fn the_person_still_writes_branches_directly() { | ||
| 152 | let harness = ServerHarness::new("delegate-person-unaffected"); | ||
| 153 | harness.push_head(); | ||
| 154 | harness.bootstrap_settings_with_cas( | ||
| 155 | &access_conf(harness.repo_name()), | ||
| 156 | &[("alex.pub", "alex")], | ||
| 157 | &[("mint/alex.pub", "mint")], | ||
| 158 | ); | ||
| 159 | harness | ||
| 160 | .work_repo() | ||
| 161 | .commit_file("p.txt", "person", "person commit"); | ||
| 162 | let push = harness.ssh_push(&harness.named_key("alex"), "main:main"); | ||
| 163 | assert!( | ||
| 164 | push.status.success(), | ||
| 165 | "the person's own push failed: {}", | ||
| 166 | stderr(&push) | ||
| 167 | ); | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Creation is a permission delegates never hold, so CREATOR can never | ||
| 171 | /// resolve to one. | ||
| 172 | #[test] | ||
| 173 | fn a_delegate_may_not_create_a_repository_its_person_could() { | ||
| 174 | let harness = ServerHarness::new("delegate-create"); | ||
| 175 | harness.push_head(); | ||
| 176 | let conf = format!( | ||
| 177 | "repo settings\n RW+ = alex\n\nrepo {}\n RW+ = alex\n\nrepo agents/[a-z-]+\n C = alex\n RW+ = CREATOR\n", | ||
| 178 | harness.repo_name() | ||
| 179 | ); | ||
| 180 | harness.bootstrap_settings_with_cas( | ||
| 181 | &conf, | ||
| 182 | &[("alex.pub", "alex")], | ||
| 183 | &[("mint/alex.pub", "mint")], | ||
| 184 | ); | ||
| 185 | |||
| 186 | let agent_key = harness.named_key("agent-key"); | ||
| 187 | let ca = harness.delegate_ca("mint"); | ||
| 188 | let cert = harness.mint_cert(&ca, &agent_key, "claude-a", "alex", "-1m:+30m"); | ||
| 189 | |||
| 190 | harness.work_repo().commit_file("n.txt", "new", "seed"); | ||
| 191 | let push = harness.ssh_push_from_cert( | ||
| 192 | harness.work_repo().dir.path(), | ||
| 193 | &agent_key, | ||
| 194 | &cert, | ||
| 195 | "agents/claude-a", | ||
| 196 | "main:main", | ||
| 197 | ); | ||
| 198 | assert!(!push.status.success(), "a delegate created a repository"); | ||
| 199 | assert!( | ||
| 200 | !harness.repos_dir().join("agents/claude-a.git").exists(), | ||
| 201 | "the repository must not exist after a refused create" | ||
| 202 | ); | ||
| 203 | } | ||