g1t/services/repos/src/run_access.rs
| 1 | //! Git with a run credential, and git's entries in the audit log. |
| 2 | //! |
| 3 | //! A sandbox's runner clones, fetches and pushes with a credential bound to |
| 4 | //! its run (see `g1t_contracts::credentials`): it may read the repositories |
| 5 | //! its run needs, push only to its pull request's fork or branch, and acts |
| 6 | //! as the person it works for once let through, so the repository's own |
| 7 | //! rules then apply to them too. Every git request a run credential makes |
| 8 | //! is recorded, and so is every push, by anyone. |
| 9 | |
| 10 | use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; |
| 11 | use g1t_contracts::credentials::{Decision, as_person, decide_git, decide_refs, limits_branches}; |
| 12 | use g1t_contracts::repos::{GitService, Repo, RepoPath}; |
| 13 | use g1t_contracts::{PrincipalKind, Viewer}; |
| 14 | use worker::js_sys::Uint8Array; |
| 15 | use worker::{Headers, Method, Request, RequestInit, Response, Result, console_error}; |
| 16 | |
| 17 | use crate::Repos; |
| 18 | use crate::git_http::GitRequest; |
| 19 | use crate::store::GitStore; |
| 20 | |
| 21 | /// What became of a git request at the door. |
| 22 | pub enum Admitted { |
| 23 | /// Go on, as `viewer`, with `request` (rebuilt if its body was read). |
| 24 | Go { |
| 25 | request: Request, |
| 26 | viewer: Viewer, |
| 27 | /// To be finished with the result and recorded. |
| 28 | entry: Option<Box<NewAuditEntry>>, |
| 29 | }, |
| 30 | Refused(Response), |
| 31 | } |
| 32 | |
| 33 | /// The refs a receive-pack request asks to change, deletions included, |
| 34 | /// read from the pkt-lines before the pack. |
| 35 | fn pushed_refs(body: &[u8]) -> Vec<String> { |
| 36 | let mut refs = Vec::new(); |
| 37 | let mut position = 0; |
| 38 | while let Some(length) = body |
| 39 | .get(position..position + 4) |
| 40 | .and_then(|hex| std::str::from_utf8(hex).ok()) |
| 41 | .and_then(|hex| usize::from_str_radix(hex, 16).ok()) |
| 42 | { |
| 43 | if length < 4 || position + length > body.len() { |
| 44 | break; |
| 45 | } |
| 46 | let line = &body[position + 4..position + length]; |
| 47 | position += length; |
| 48 | let command = line.split(|byte| *byte == 0).next().unwrap_or_default(); |
| 49 | let Ok(command) = std::str::from_utf8(command) else { |
| 50 | continue; |
| 51 | }; |
| 52 | let mut parts = command.trim_end().splitn(3, ' '); |
| 53 | if let (Some(_old), Some(_new), Some(name)) = (parts.next(), parts.next(), parts.next()) { |
| 54 | refs.push(name.to_owned()); |
| 55 | } |
| 56 | } |
| 57 | refs |
| 58 | } |
| 59 | |
| 60 | /// The same request again, with the body already read. |
| 61 | fn rebuilt(request: &Request, body: &[u8]) -> Result<Request> { |
| 62 | let headers = Headers::new(); |
| 63 | for (name, value) in request.headers().entries() { |
| 64 | headers.set(&name, &value)?; |
| 65 | } |
| 66 | let mut init = RequestInit::new(); |
| 67 | init.with_method(Method::Post) |
| 68 | .with_headers(headers) |
| 69 | .with_body(Some(Uint8Array::from(body).into())); |
| 70 | Request::new_with_init(request.url()?.as_str(), &init) |
| 71 | } |
| 72 | |
| 73 | fn refusal(decision: &Decision) -> Result<Response> { |
| 74 | Response::error( |
| 75 | decision |
| 76 | .reason |
| 77 | .clone() |
| 78 | .unwrap_or_else(|| "Not allowed.".to_owned()), |
| 79 | 403, |
| 80 | ) |
| 81 | } |
| 82 | |
| 83 | impl<S: GitStore> Repos<S> { |
| 84 | /// Where a git request's entry belongs: the repository a fork came |
| 85 | /// from, so that a pull request's pushes are in its workspace's log. |
| 86 | pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> { |
| 87 | let found = self.registry.by_path(path).await?; |
| 88 | self.audit_target_of(path, found.as_ref()).await |
| 89 | } |
| 90 | |
| 91 | /// The same, for the repository at `path` as already read (`found`). |
| 92 | async fn audit_target_of(&self, path: &RepoPath, found: Option<&Repo>) -> Result<AuditTarget> { |
| 93 | let mut repo = path.clone(); |
| 94 | if let Some(found) = found |
| 95 | && let Some(source) = found.fork_of.as_deref() |
| 96 | && let Some(source) = self.registry.by_id(source).await? |
| 97 | { |
| 98 | repo = RepoPath { |
| 99 | namespace: source.namespace, |
| 100 | name: source.name, |
| 101 | }; |
| 102 | } |
| 103 | Ok(AuditTarget { |
| 104 | workspace: repo.namespace.to_lowercase(), |
| 105 | repo: Some(format!("{}/{}", repo.namespace, repo.name)), |
| 106 | ..AuditTarget::default() |
| 107 | }) |
| 108 | } |
| 109 | |
| 110 | pub(crate) async fn record_git(&self, entry: NewAuditEntry) { |
| 111 | let recorded: Result<u32> = g1t_kit::call( |
| 112 | &self.events, |
| 113 | "audit_record", |
| 114 | &RecordAuditArgs { |
| 115 | entries: vec![entry], |
| 116 | }, |
| 117 | ) |
| 118 | .await; |
| 119 | if let Err(error) = recorded { |
| 120 | console_error!("git audit entry not recorded: {error}"); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// Checks a run credential against its grants before anything else |
| 125 | /// sees the request, and starts the request's audit entry. |
| 126 | pub(crate) async fn admit_git( |
| 127 | &self, |
| 128 | mut request: Request, |
| 129 | git: &GitRequest, |
| 130 | viewer: Viewer, |
| 131 | found: Option<&Repo>, |
| 132 | ) -> Result<Admitted> { |
| 133 | let post = request.method() == Method::Post; |
| 134 | let write = git.service == GitService::ReceivePack; |
| 135 | let action = if write { "git.push" } else { "git.fetch" }; |
| 136 | let request_id = request |
| 137 | .headers() |
| 138 | .get("cf-ray")? |
| 139 | .unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())); |
| 140 | let Some(user) = viewer.clone() else { |
| 141 | return Ok(Admitted::Go { |
| 142 | request, |
| 143 | viewer, |
| 144 | entry: None, |
| 145 | }); |
| 146 | }; |
| 147 | let run_scope = user |
| 148 | .acting |
| 149 | .as_ref() |
| 150 | .filter(|_| user.kind == PrincipalKind::Agent) |
| 151 | .map(|acting| acting.scope.clone()); |
| 152 | let entry = |target: AuditTarget, decision: &Decision| { |
| 153 | NewAuditEntry::new( |
| 154 | AuditActor::of(&user), |
| 155 | action, |
| 156 | Surface::Git, |
| 157 | target, |
| 158 | decision, |
| 159 | request_id.clone(), |
| 160 | ) |
| 161 | }; |
| 162 | |
| 163 | let Some(scope) = run_scope else { |
| 164 | // People and workspace tokens: their pushes are recorded; the |
| 165 | // repository's own rules decide. |
| 166 | let pushing = post && write && git.endpoint == "git-receive-pack"; |
| 167 | let entry = if pushing { |
| 168 | let rule = match user.kind { |
| 169 | PrincipalKind::Workspace => "workspace-token", |
| 170 | PrincipalKind::Agent => "agent-token", |
| 171 | PrincipalKind::User => "person", |
| 172 | PrincipalKind::System => "g1t", |
| 173 | }; |
| 174 | Some(Box::new(entry( |
| 175 | self.audit_target_of(&git.path, found).await?, |
| 176 | &Decision::allow(rule), |
| 177 | ))) |
| 178 | } else { |
| 179 | None |
| 180 | }; |
| 181 | return Ok(Admitted::Go { |
| 182 | request, |
| 183 | viewer, |
| 184 | entry, |
| 185 | }); |
| 186 | }; |
| 187 | |
| 188 | let mut decision = decide_git(&scope, &git.path, write); |
| 189 | let mut refs = Vec::new(); |
| 190 | if decision.allowed && post && write && limits_branches(&scope, &git.path) { |
| 191 | let body = request.bytes().await?; |
| 192 | refs = pushed_refs(&body); |
| 193 | decision = decide_refs(&scope, &git.path, &refs); |
| 194 | request = rebuilt(&request, &body)?; |
| 195 | } else if decision.allowed && post && write { |
| 196 | // A fork is the pull request's own: any branch of it. |
| 197 | refs.clear(); |
| 198 | } |
| 199 | let mut target = self.audit_target_of(&git.path, found).await?; |
| 200 | if !refs.is_empty() { |
| 201 | target.git_ref = Some(refs.join(" ")); |
| 202 | } |
| 203 | if !decision.allowed { |
| 204 | let mut refused = entry(target, &decision); |
| 205 | refused.result = Some("forbidden".to_owned()); |
| 206 | self.record_git(refused).await; |
| 207 | return Ok(Admitted::Refused(refusal(&decision)?)); |
| 208 | } |
| 209 | // Once through, the person it works for, with the run's workspace |
| 210 | // only: what they may do decides the rest. |
| 211 | let person = as_person(&user); |
| 212 | Ok(Admitted::Go { |
| 213 | request, |
| 214 | viewer: person, |
| 215 | // Only the requests that move data are worth a line each. |
| 216 | entry: post.then(|| Box::new(entry(target, &decision))), |
| 217 | }) |
| 218 | } |
| 219 | |
| 220 | /// Records a git request's entry with how it ended: `status` is the |
| 221 | /// HTTP status git was answered with. |
| 222 | pub(crate) async fn finish_git( |
| 223 | &self, |
| 224 | entry: Option<Box<NewAuditEntry>>, |
| 225 | status: u16, |
| 226 | message: Option<String>, |
| 227 | ) { |
| 228 | let Some(mut entry) = entry.map(|entry| *entry) else { |
| 229 | return; |
| 230 | }; |
| 231 | if status == 200 { |
| 232 | entry.result = Some("ok".to_owned()); |
| 233 | } else { |
| 234 | entry.result = Some(status.to_string()); |
| 235 | if matches!(status, 401 | 403 | 404) { |
| 236 | entry.outcome = g1t_contracts::audit::AuditOutcome::Denied; |
| 237 | entry.rule = "repository".to_owned(); |
| 238 | } |
| 239 | entry.message = message; |
| 240 | } |
| 241 | self.record_git(entry).await; |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | #[cfg(test)] |
| 246 | mod tests { |
| 247 | use super::pushed_refs; |
| 248 | |
| 249 | fn pkt(payload: &str) -> Vec<u8> { |
| 250 | format!("{:04x}{payload}", payload.len() + 4).into_bytes() |
| 251 | } |
| 252 | |
| 253 | #[test] |
| 254 | fn every_ref_a_push_names_is_read() { |
| 255 | let old = "c71546fcd893ef8b0f57388b65e620d759705dda"; |
| 256 | let new = "4807077b296e6edbf410d55e72749d3e1170c291"; |
| 257 | let zero = "0000000000000000000000000000000000000000"; |
| 258 | let body = [ |
| 259 | pkt(&format!( |
| 260 | "{old} {new} refs/heads/fix\0 report-status side-band-64k\n" |
| 261 | )), |
| 262 | pkt(&format!("{old} {zero} refs/heads/main\n")), |
| 263 | pkt(&format!("{zero} {new} refs/tags/v1\n")), |
| 264 | b"0000".to_vec(), |
| 265 | b"PACK\0\0\0\x02".to_vec(), |
| 266 | ] |
| 267 | .concat(); |
| 268 | assert_eq!( |
| 269 | pushed_refs(&body), |
| 270 | ["refs/heads/fix", "refs/heads/main", "refs/tags/v1"] |
| 271 | ); |
| 272 | assert!(pushed_refs(b"0000").is_empty()); |
| 273 | } |
| 274 | } |