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.

g1t.sh
358 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, 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
10use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
11use g1t_contracts::credentials::{Decision, as_person, decide_git, decide_refs, limits_branches};
12use g1t_contracts::repos::{GitService, Repo, RepoPath};
13use g1t_contracts::{PrincipalKind, Viewer};
14use worker::js_sys::Uint8Array;
15use worker::{Headers, Method, Request, RequestInit, Response, Result, console_error};
16
17use crate::Repos;
18use crate::git_http::GitRequest;
19use crate::store::GitStore;
20
21/// What became of a git request at the door.
22pub 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.
34pub 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.
43pub(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.
57pub(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.
66pub(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.
72fn 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.
98fn 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
110fn 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
120impl<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)]
277mod 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)]
330mod 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}