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

266 lines9,509 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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