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