flagon-io/g1t

public

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/services/repos/src/run_access.rs

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