g1t/services/repos/src/run_access.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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, 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. | |
| Catching up with main takes seconds when the two sides touched different files | 86 | pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 87 | let mut repo = path.clone(); |
| 88 | if let Some(found) = self.registry.by_path(path).await? | |
| 89 | && let Some(source) = found.fork_of.as_deref() | |
| 90 | && let Some(source) = self.registry.by_id(source).await? | |
| 91 | { | |
| 92 | repo = RepoPath { | |
| 93 | namespace: source.namespace, | |
| 94 | name: source.name, | |
| 95 | }; | |
| 96 | } | |
| 97 | Ok(AuditTarget { | |
| 98 | workspace: repo.namespace.to_lowercase(), | |
| 99 | repo: Some(format!("{}/{}", repo.namespace, repo.name)), | |
| 100 | ..AuditTarget::default() | |
| 101 | }) | |
| 102 | } | |
| 103 | ||
| Catching up with main takes seconds when the two sides touched different files | 104 | pub(crate) async fn record_git(&self, entry: NewAuditEntry) { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 105 | let recorded: Result<u32> = g1t_kit::call( |
| 106 | &self.events, | |
| 107 | "audit_record", | |
| 108 | &RecordAuditArgs { | |
| 109 | entries: vec![entry], | |
| 110 | }, | |
| 111 | ) | |
| 112 | .await; | |
| 113 | if let Err(error) = recorded { | |
| 114 | console_error!("git audit entry not recorded: {error}"); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /// Checks a run credential against its grants before anything else | |
| 119 | /// sees the request, and starts the request's audit entry. | |
| 120 | pub(crate) async fn admit_git( | |
| 121 | &self, | |
| 122 | mut request: Request, | |
| 123 | git: &GitRequest, | |
| 124 | viewer: Viewer, | |
| 125 | ) -> Result<Admitted> { | |
| 126 | let post = request.method() == Method::Post; | |
| 127 | let write = git.service == GitService::ReceivePack; | |
| 128 | let action = if write { "git.push" } else { "git.fetch" }; | |
| 129 | let request_id = request | |
| 130 | .headers() | |
| 131 | .get("cf-ray")? | |
| 132 | .unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())); | |
| 133 | let Some(user) = viewer.clone() else { | |
| 134 | return Ok(Admitted::Go { | |
| 135 | request, | |
| 136 | viewer, | |
| 137 | entry: None, | |
| 138 | }); | |
| 139 | }; | |
| 140 | let run_scope = user | |
| 141 | .acting | |
| 142 | .as_ref() | |
| 143 | .filter(|_| user.kind == PrincipalKind::Agent) | |
| 144 | .map(|acting| acting.scope.clone()); | |
| 145 | let entry = |target: AuditTarget, decision: &Decision| { | |
| 146 | NewAuditEntry::new( | |
| 147 | AuditActor::of(&user), | |
| 148 | action, | |
| 149 | Surface::Git, | |
| 150 | target, | |
| 151 | decision, | |
| 152 | request_id.clone(), | |
| 153 | ) | |
| 154 | }; | |
| 155 | ||
| 156 | let Some(scope) = run_scope else { | |
| 157 | // People and workspace tokens: their pushes are recorded; the | |
| 158 | // repository's own rules decide. | |
| 159 | let pushing = post && write && git.endpoint == "git-receive-pack"; | |
| 160 | let entry = if pushing { | |
| 161 | let rule = match user.kind { | |
| 162 | PrincipalKind::Workspace => "workspace-token", | |
| 163 | PrincipalKind::Agent => "agent-token", | |
| 164 | PrincipalKind::User => "person", | |
| 165 | }; | |
| 166 | Some(Box::new(entry( | |
| 167 | self.audit_target(&git.path).await?, | |
| 168 | &Decision::allow(rule), | |
| 169 | ))) | |
| 170 | } else { | |
| 171 | None | |
| 172 | }; | |
| 173 | return Ok(Admitted::Go { | |
| 174 | request, | |
| 175 | viewer, | |
| 176 | entry, | |
| 177 | }); | |
| 178 | }; | |
| 179 | ||
| 180 | let mut decision = decide_git(&scope, &git.path, write); | |
| 181 | let mut refs = Vec::new(); | |
| 182 | if decision.allowed && post && write && limits_branches(&scope, &git.path) { | |
| 183 | let body = request.bytes().await?; | |
| 184 | refs = pushed_refs(&body); | |
| 185 | decision = decide_refs(&scope, &git.path, &refs); | |
| 186 | request = rebuilt(&request, &body)?; | |
| 187 | } else if decision.allowed && post && write { | |
| 188 | // A fork is the pull request's own: any branch of it. | |
| 189 | refs.clear(); | |
| 190 | } | |
| 191 | let mut target = self.audit_target(&git.path).await?; | |
| 192 | if !refs.is_empty() { | |
| 193 | target.git_ref = Some(refs.join(" ")); | |
| 194 | } | |
| 195 | if !decision.allowed { | |
| 196 | let mut refused = entry(target, &decision); | |
| 197 | refused.result = Some("forbidden".to_owned()); | |
| 198 | self.record_git(refused).await; | |
| 199 | return Ok(Admitted::Refused(refusal(&decision)?)); | |
| 200 | } | |
| 201 | // Once through, the person it works for, with the run's workspace | |
| 202 | // only: what they may do decides the rest. | |
| 203 | let person = as_person(&user); | |
| 204 | Ok(Admitted::Go { | |
| 205 | request, | |
| 206 | viewer: person, | |
| 207 | // Only the requests that move data are worth a line each. | |
| 208 | entry: post.then(|| Box::new(entry(target, &decision))), | |
| 209 | }) | |
| 210 | } | |
| 211 | ||
| 212 | /// Records a git request's entry with how it ended: `status` is the | |
| 213 | /// HTTP status git was answered with. | |
| 214 | pub(crate) async fn finish_git( | |
| 215 | &self, | |
| 216 | entry: Option<Box<NewAuditEntry>>, | |
| 217 | status: u16, | |
| 218 | message: Option<String>, | |
| 219 | ) { | |
| 220 | let Some(mut entry) = entry.map(|entry| *entry) else { | |
| 221 | return; | |
| 222 | }; | |
| 223 | if status == 200 { | |
| 224 | entry.result = Some("ok".to_owned()); | |
| 225 | } else { | |
| 226 | entry.result = Some(status.to_string()); | |
| 227 | if matches!(status, 401 | 403 | 404) { | |
| 228 | entry.outcome = g1t_contracts::audit::AuditOutcome::Denied; | |
| 229 | entry.rule = "repository".to_owned(); | |
| 230 | } | |
| 231 | entry.message = message; | |
| 232 | } | |
| 233 | self.record_git(entry).await; | |
| 234 | } | |
| 235 | } | |
| 236 | ||
| 237 | #[cfg(test)] | |
| 238 | mod tests { | |
| 239 | use super::pushed_refs; | |
| 240 | ||
| 241 | fn pkt(payload: &str) -> Vec<u8> { | |
| 242 | format!("{:04x}{payload}", payload.len() + 4).into_bytes() | |
| 243 | } | |
| 244 | ||
| 245 | #[test] | |
| 246 | fn every_ref_a_push_names_is_read() { | |
| 247 | let old = "c71546fcd893ef8b0f57388b65e620d759705dda"; | |
| 248 | let new = "4807077b296e6edbf410d55e72749d3e1170c291"; | |
| 249 | let zero = "0000000000000000000000000000000000000000"; | |
| 250 | let body = [ | |
| 251 | pkt(&format!( | |
| 252 | "{old} {new} refs/heads/fix\0 report-status side-band-64k\n" | |
| 253 | )), | |
| 254 | pkt(&format!("{old} {zero} refs/heads/main\n")), | |
| 255 | pkt(&format!("{zero} {new} refs/tags/v1\n")), | |
| 256 | b"0000".to_vec(), | |
| 257 | b"PACK\0\0\0\x02".to_vec(), | |
| 258 | ] | |
| 259 | .concat(); | |
| 260 | assert_eq!( | |
| 261 | pushed_refs(&body), | |
| 262 | ["refs/heads/fix", "refs/heads/main", "refs/tags/v1"] | |
| 263 | ); | |
| 264 | assert!(pushed_refs(b"0000").is_empty()); | |
| 265 | } | |
| 266 | } |