| 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<GitEntry>>, |
| 29 | }, |
| 30 | Refused(Response), |
| 31 | } |
| 32 | |
| 33 | /// A git request's audit entry, before its result is known. |
| 34 | pub struct GitEntry { |
| 35 | pub entry: NewAuditEntry, |
| 36 | /// Recorded only if the request is refused: git's first request of a |
| 37 | /// push, which is where a credential that may not push is turned away. |
| 38 | pub refused_only: bool, |
| 39 | } |
| 40 | |
| 41 | /// The rule a push by someone other than a run is recorded under: who |
| 42 | /// pushed, as the repository's own rules then decide. |
| 43 | pub(crate) fn push_rule(user: &g1t_contracts::User) -> &'static str { |
| 44 | match user.kind { |
| 45 | PrincipalKind::Workspace if user.token.as_deref().is_some_and(|token| token.deploy_key.is_some()) => "deploy-key", |
| 46 | PrincipalKind::Workspace => "workspace-token", |
| 47 | PrincipalKind::Agent => "agent-token", |
| 48 | PrincipalKind::User => "person", |
| 49 | PrincipalKind::System => "g1t", |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | /// Whether a request of a push is recorded: the pack (`Some(false)`, |
| 54 | /// always) and git's first request for the refs it may push to |
| 55 | /// (`Some(true)`, only if refused). Fetches by people and workspace |
| 56 | /// tokens are not recorded. |
| 57 | pub(crate) fn push_entry(post: bool, write: bool, endpoint: &str) -> Option<bool> { |
| 58 | match (write, post) { |
| 59 | (true, true) if endpoint == "git-receive-pack" => Some(false), |
| 60 | (true, false) => Some(true), |
| 61 | _ => None, |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// Whether an entry is kept, given the HTTP status git was answered with. |
| 66 | pub(crate) fn kept(refused_only: bool, status: u16) -> bool { |
| 67 | !refused_only || status >= 400 |
| 68 | } |
| 69 | |
| 70 | /// The refs a receive-pack request asks to change, deletions included, |
| 71 | /// read from the pkt-lines before the pack. |
| 72 | fn pushed_refs(body: &[u8]) -> Vec<String> { |
| 73 | let mut refs = Vec::new(); |
| 74 | let mut position = 0; |
| 75 | while let Some(length) = body |
| 76 | .get(position..position + 4) |
| 77 | .and_then(|hex| std::str::from_utf8(hex).ok()) |
| 78 | .and_then(|hex| usize::from_str_radix(hex, 16).ok()) |
| 79 | { |
| 80 | if length < 4 || position + length > body.len() { |
| 81 | break; |
| 82 | } |
| 83 | let line = &body[position + 4..position + length]; |
| 84 | position += length; |
| 85 | let command = line.split(|byte| *byte == 0).next().unwrap_or_default(); |
| 86 | let Ok(command) = std::str::from_utf8(command) else { |
| 87 | continue; |
| 88 | }; |
| 89 | let mut parts = command.trim_end().splitn(3, ' '); |
| 90 | if let (Some(_old), Some(_new), Some(name)) = (parts.next(), parts.next(), parts.next()) { |
| 91 | refs.push(name.to_owned()); |
| 92 | } |
| 93 | } |
| 94 | refs |
| 95 | } |
| 96 | |
| 97 | /// The same request again, with the body already read. |
| 98 | fn rebuilt(request: &Request, body: &[u8]) -> Result<Request> { |
| 99 | let headers = Headers::new(); |
| 100 | for (name, value) in request.headers().entries() { |
| 101 | headers.set(&name, &value)?; |
| 102 | } |
| 103 | let mut init = RequestInit::new(); |
| 104 | init.with_method(Method::Post) |
| 105 | .with_headers(headers) |
| 106 | .with_body(Some(Uint8Array::from(body).into())); |
| 107 | Request::new_with_init(request.url()?.as_str(), &init) |
| 108 | } |
| 109 | |
| 110 | fn refusal(decision: &Decision) -> Result<Response> { |
| 111 | Response::error( |
| 112 | decision |
| 113 | .reason |
| 114 | .clone() |
| 115 | .unwrap_or_else(|| "Not allowed.".to_owned()), |
| 116 | 403, |
| 117 | ) |
| 118 | } |
| 119 | |
| 120 | impl<S: GitStore> Repos<S> { |
| 121 | /// Where a git request's entry belongs: the repository a fork came |
| 122 | /// from, so that a pull request's pushes are in its workspace's log. |
| 123 | pub(crate) async fn audit_target(&self, path: &RepoPath) -> Result<AuditTarget> { |
| 124 | let found = self.registry.by_path(path).await?; |
| 125 | self.audit_target_of(path, found.as_ref()).await |
| 126 | } |
| 127 | |
| 128 | /// The same, for the repository at `path` as already read (`found`). |
| 129 | async fn audit_target_of(&self, path: &RepoPath, found: Option<&Repo>) -> Result<AuditTarget> { |
| 130 | let mut repo = path.clone(); |
| 131 | if let Some(found) = found |
| 132 | && let Some(source) = found.fork_of.as_deref() |
| 133 | && let Some(source) = self.registry.by_id(source).await? |
| 134 | { |
| 135 | repo = RepoPath { |
| 136 | namespace: source.namespace, |
| 137 | name: source.name, |
| 138 | }; |
| 139 | } |
| 140 | Ok(AuditTarget { |
| 141 | workspace: repo.namespace.to_lowercase(), |
| 142 | repo: Some(format!("{}/{}", repo.namespace, repo.name)), |
| 143 | ..AuditTarget::default() |
| 144 | }) |
| 145 | } |
| 146 | |
| 147 | pub(crate) async fn record_git(&self, entry: NewAuditEntry) { |
| 148 | let recorded: Result<u32> = g1t_kit::call( |
| 149 | &self.events, |
| 150 | "audit_record", |
| 151 | &RecordAuditArgs { |
| 152 | entries: vec![entry], |
| 153 | }, |
| 154 | ) |
| 155 | .await; |
| 156 | if let Err(error) = recorded { |
| 157 | console_error!("git audit entry not recorded: {error}"); |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// Checks a run credential against its grants before anything else |
| 162 | /// sees the request, and starts the request's audit entry. |
| 163 | pub(crate) async fn admit_git( |
| 164 | &self, |
| 165 | mut request: Request, |
| 166 | git: &GitRequest, |
| 167 | viewer: Viewer, |
| 168 | found: Option<&Repo>, |
| 169 | ) -> Result<Admitted> { |
| 170 | let post = request.method() == Method::Post; |
| 171 | let write = git.service == GitService::ReceivePack; |
| 172 | let action = if write { "git.push" } else { "git.fetch" }; |
| 173 | let request_id = request |
| 174 | .headers() |
| 175 | .get("cf-ray")? |
| 176 | .unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())); |
| 177 | let Some(user) = viewer.clone() else { |
| 178 | return Ok(Admitted::Go { |
| 179 | request, |
| 180 | viewer, |
| 181 | entry: None, |
| 182 | }); |
| 183 | }; |
| 184 | // A run credential carries its scope; whatever kind of principal |
| 185 | // it resolves as, it is held to that scope. |
| 186 | let run_scope = user.acting.as_ref().map(|acting| acting.scope.clone()); |
| 187 | let entry = |target: AuditTarget, decision: &Decision| { |
| 188 | NewAuditEntry::new( |
| 189 | AuditActor::of(&user), |
| 190 | action, |
| 191 | Surface::Git, |
| 192 | target, |
| 193 | decision, |
| 194 | request_id.clone(), |
| 195 | ) |
| 196 | }; |
| 197 | |
| 198 | let Some(scope) = run_scope else { |
| 199 | // People, workspace tokens and deploy keys: their pushes are |
| 200 | // recorded, and so is a push refused at its first request; the |
| 201 | // repository's own rules decide. |
| 202 | let entry = match push_entry(post, write, git.endpoint) { |
| 203 | Some(refused_only) => Some(Box::new(GitEntry { |
| 204 | entry: entry(self.audit_target_of(&git.path, found).await?, &Decision::allow(push_rule(&user))), |
| 205 | refused_only, |
| 206 | })), |
| 207 | None => None, |
| 208 | }; |
| 209 | return Ok(Admitted::Go { |
| 210 | request, |
| 211 | viewer, |
| 212 | entry, |
| 213 | }); |
| 214 | }; |
| 215 | |
| 216 | let mut decision = decide_git(&scope, &git.path, write); |
| 217 | let mut refs = Vec::new(); |
| 218 | if decision.allowed && post && write && limits_branches(&scope, &git.path) { |
| 219 | let body = request.bytes().await?; |
| 220 | refs = pushed_refs(&body); |
| 221 | decision = decide_refs(&scope, &git.path, &refs); |
| 222 | request = rebuilt(&request, &body)?; |
| 223 | } else if decision.allowed && post && write { |
| 224 | // A fork is the pull request's own: any branch of it. |
| 225 | refs.clear(); |
| 226 | } |
| 227 | let mut target = self.audit_target_of(&git.path, found).await?; |
| 228 | if !refs.is_empty() { |
| 229 | target.git_ref = Some(refs.join(" ")); |
| 230 | } |
| 231 | if !decision.allowed { |
| 232 | let mut refused = entry(target, &decision); |
| 233 | refused.result = Some("forbidden".to_owned()); |
| 234 | self.record_git(refused).await; |
| 235 | return Ok(Admitted::Refused(refusal(&decision)?)); |
| 236 | } |
| 237 | // Once through, the person it works for, with the run's workspace |
| 238 | // only: what they may do decides the rest. |
| 239 | let person = as_person(&user); |
| 240 | Ok(Admitted::Go { |
| 241 | request, |
| 242 | viewer: person, |
| 243 | // Only the requests that move data are worth a line each. |
| 244 | entry: post.then(|| Box::new(GitEntry { entry: entry(target, &decision), refused_only: false })), |
| 245 | }) |
| 246 | } |
| 247 | |
| 248 | /// Records a git request's entry with how it ended: `status` is the |
| 249 | /// HTTP status git was answered with. |
| 250 | pub(crate) async fn finish_git( |
| 251 | &self, |
| 252 | entry: Option<Box<GitEntry>>, |
| 253 | status: u16, |
| 254 | message: Option<String>, |
| 255 | ) { |
| 256 | let Some(GitEntry { mut entry, refused_only }) = entry.map(|entry| *entry) else { |
| 257 | return; |
| 258 | }; |
| 259 | if !kept(refused_only, status) { |
| 260 | return; |
| 261 | } |
| 262 | if status == 200 { |
| 263 | entry.result = Some("ok".to_owned()); |
| 264 | } else { |
| 265 | entry.result = Some(status.to_string()); |
| 266 | if matches!(status, 401 | 403 | 404) { |
| 267 | entry.outcome = g1t_contracts::audit::AuditOutcome::Denied; |
| 268 | entry.rule = "repository".to_owned(); |
| 269 | } |
| 270 | entry.message = message; |
| 271 | } |
| 272 | self.record_git(entry).await; |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | #[cfg(test)] |
| 277 | mod audit_tests { |
| 278 | use super::{kept, push_entry, push_rule}; |
| 279 | use g1t_contracts::User; |
| 280 | use g1t_contracts::audit::{ActorKind, AuditActor}; |
| 281 | |
| 282 | fn workspace_token() -> User { |
| 283 | serde_json::from_value(serde_json::json!({ |
| 284 | "id": "wsp_flagon", |
| 285 | "username": "flagon-io", |
| 286 | "kind": "workspace", |
| 287 | "token": { "token_id": "tok_publish", "scopes": ["code:read", "code:write"] }, |
| 288 | })) |
| 289 | .unwrap() |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn a_workspace_tokens_push_is_the_workspaces_with_its_token() { |
| 294 | let user = workspace_token(); |
| 295 | let actor = AuditActor::of(&user); |
| 296 | assert_eq!(actor.actor_kind, Some(ActorKind::Workspace)); |
| 297 | assert_eq!((actor.actor.as_str(), actor.actor_id.as_str()), ("flagon-io", "wsp_flagon")); |
| 298 | assert_eq!(actor.credential_id.as_deref(), Some("tok_publish")); |
| 299 | assert_eq!(actor.on_behalf_of, None); |
| 300 | assert_eq!(push_rule(&user), "workspace-token"); |
| 301 | } |
| 302 | |
| 303 | #[test] |
| 304 | fn a_deploy_key_is_told_apart_from_a_workspace_token() { |
| 305 | let key = g1t_contracts::deploy_keys::principal( |
| 306 | "wsp_acme", |
| 307 | "acme", |
| 308 | g1t_contracts::deploy_keys::access("dk_1", "CI", "acme/rocket", false), |
| 309 | ); |
| 310 | assert_eq!(push_rule(&key), "deploy-key"); |
| 311 | assert_eq!(AuditActor::of(&key).credential_id.as_deref(), Some("dk_1")); |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn a_push_is_recorded_and_so_is_one_refused_at_its_first_request() { |
| 316 | // The pack: always. |
| 317 | assert_eq!(push_entry(true, true, "git-receive-pack"), Some(false)); |
| 318 | // git's first request for the refs it may push to: if refused. |
| 319 | assert_eq!(push_entry(false, true, "info/refs"), Some(true)); |
| 320 | assert!(kept(true, 404) && kept(true, 403) && kept(true, 401)); |
| 321 | assert!(!kept(true, 200), "an allowed push is recorded once, with its pack"); |
| 322 | assert!(kept(false, 200)); |
| 323 | // Fetches are not recorded. |
| 324 | assert_eq!(push_entry(false, false, "info/refs"), None); |
| 325 | assert_eq!(push_entry(true, false, "git-upload-pack"), None); |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | #[cfg(test)] |
| 330 | mod tests { |
| 331 | use super::pushed_refs; |
| 332 | |
| 333 | fn pkt(payload: &str) -> Vec<u8> { |
| 334 | format!("{:04x}{payload}", payload.len() + 4).into_bytes() |
| 335 | } |
| 336 | |
| 337 | #[test] |
| 338 | fn every_ref_a_push_names_is_read() { |
| 339 | let old = "c71546fcd893ef8b0f57388b65e620d759705dda"; |
| 340 | let new = "4807077b296e6edbf410d55e72749d3e1170c291"; |
| 341 | let zero = "0000000000000000000000000000000000000000"; |
| 342 | let body = [ |
| 343 | pkt(&format!( |
| 344 | "{old} {new} refs/heads/fix\0 report-status side-band-64k\n" |
| 345 | )), |
| 346 | pkt(&format!("{old} {zero} refs/heads/main\n")), |
| 347 | pkt(&format!("{zero} {new} refs/tags/v1\n")), |
| 348 | b"0000".to_vec(), |
| 349 | b"PACK\0\0\0\x02".to_vec(), |
| 350 | ] |
| 351 | .concat(); |
| 352 | assert_eq!( |
| 353 | pushed_refs(&body), |
| 354 | ["refs/heads/fix", "refs/heads/main", "refs/tags/v1"] |
| 355 | ); |
| 356 | assert!(pushed_refs(b"0000").is_empty()); |
| 357 | } |
| 358 | } |