Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

369 linesCodeBlameRaw
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
11use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
12use g1t_contracts::credentials::{Decision, RunCredentialKind, as_person, decide_git_at, decide_refs, limits_branches};
13use g1t_contracts::repos::{GitService, Repo, RepoPath};
14use g1t_contracts::{PrincipalKind, Viewer};
15use worker::js_sys::Uint8Array;
16use worker::{Headers, Method, Request, RequestInit, Response, Result, console_error};
17
18use crate::Repos;
19use crate::git_http::GitRequest;
20use crate::store::GitStore;
21
22/// What became of a git request at the door.
23pub 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.
35pub 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.
44pub(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.
58pub(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.
67pub(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.
73fn 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.
99fn 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
111fn 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
121impl<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)]
288mod 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)]
341mod 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}