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

Commit

The audit log records a push refused at git's first request, tells deploy keys from workspace tokens, and the activity digest names a workspace as itself. git asks for the refs it may push to (GET info/refs?service=git-receive-pack) before it sends a pack, and that is where a credential that does not reach the repository or may not push is refused, so such a push left no entry: only the pack's POST was recorded. Repos now starts an entry for that first request too and records it only when it is refused (any 4xx or 5xx), as a denied git.push with the refusal's own message, so an allowed push is still one entry. A push allowed through is recorded under the rule for what pushed: person, workspace-token, deploy-key (new; a deploy key resolves as its workspace, and was recorded as workspace-token) or agent-token; the audit page labels deploy-key. The digest counted any wsp_ actor as g1t's agent (user:usr_g1t_agent), so a workspace token's pushes and changes were g1t's; it now keeps them the workspace's (user:wsp_<id>), which the site already never counts among the people. The audit log guide says what a refused push records, who the actor is and which rules a push carries. Checked: cargo test -p g1t-repos --lib (317, new: a workspace token's push is the workspace's with its token id and no person, a deploy key has its own rule, a push and one refused at its first request are recorded and an allowed first request is not, fetches are not), cargo test -p g1t-events --lib (55) and wasm32 checks of both; the site's audit and home tests (27) and typecheck; the docs build.

syntaqxcommitted Parentb235b5eBrowse files
8 files+130−340/8 viewed
+6−4
6363 operation's name too, such as `delete_repo`. See
6464 [managing a repository](/guides/managing-repositories/).
6565
66−Refusals are recorded too, with the rule that refused them. Entries are
67−only ever added: nothing edits or removes one.
66+Refusals are recorded too, with the rule that refused them. A push
67+refused before it sends anything, such as one with a token that does not
68+reach the repository or may not push, is recorded as a denied `git.push`.
69+Entries are only ever added: nothing edits or removes one.
6870
6971 ## What an entry says
7072
7173 | Field | What it is |
7274 | --- | --- |
7375 | Time | When it happened, to the millisecond. |
74−| Actor | Who did it: a person or a workspace token. |
76+| Actor | Who did it: a person, or the workspace for its own tokens and deploy keys. |
7577 | Run | The workflow run whose job's token did it, as `workflow_job`. |
7678 | Credential | The id of the token used: on the API, the MCP server or git, or on g1t.sh by a token [used on the website](/guides/authentication/#use-a-token-on-the-website). Empty for a person signed in on g1t.sh. |
7779 | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. |
7880 | Target | The repository, the issue or pull request number, and for git the refs it moved. |
7981 | Outcome | `allowed` or `denied`. |
80−| Rule | What decided it: a refusal rule, such as `scope:repository`, or `token:repository` for a workflow job's token used on another repository; or, for people, their own access. A refusal by the repository's own rules is `service` (or `repository` for git). |
82+| Rule | What decided it: a refusal rule, such as `scope:repository`, or `token:repository` for a workflow job's token used on another repository; or, for people, their own access. A push allowed through is `person`, `workspace-token`, `deploy-key` or `agent-token`, after what pushed. A refusal by the repository's own rules is `service` (or `repository` for git). |
8183 | Result | `ok`, or the reason it failed. |
8284 | Request id | The request's id, the same one Cloudflare logs it under. |
8385
+2−0
8080 assert.equal(ruleLabel("never"), "never allowed for agents (an old entry)");
8181 assert.equal(ruleLabel("run:implement/tools"), "implement run tools");
8282 assert.equal(ruleLabel("run:update/runner:push"), "update run runner (push)");
83+ assert.equal(ruleLabel("workspace-token"), "the workspace token's access");
84+ assert.equal(ruleLabel("deploy-key"), "the deploy key's access");
8385 });
8486
8587 test("the CSV quotes what it must and keeps formulas as text", () => {
+1−0
198198 if (rule === "service" || rule === "repository") return "refused by the repository's rules";
199199 if (rule === "person") return "the person's own access";
200200 if (rule === "workspace-token") return "the workspace token's access";
201+ if (rule === "deploy-key") return "the deploy key's access";
201202 const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule);
202203 if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`;
203204 return rule;
+1−1
363363 assert.equal(actorKind(MARGO), "agent");
364364 assert.equal(actorKind(G1T), "agent");
365365 assert.equal(actorKind("user:agt_01abc"), "agent", "an agent named by its own id under user: is still a robot");
366− assert.equal(actorKind("user:wsp_01abc"), "agent", "the workspace's own upkeep is g1t's");
366+ assert.equal(actorKind("user:wsp_01abc"), "agent", "a workspace, by its own token or g1t's upkeep, is never a person");
367367 assert.equal(actorKind("user:g1t"), "agent");
368368 assert.equal(actorKind(ANA), "person");
369369 assert.equal(actorKind(""), "person");
+2−3
206206
207207 /**
208208 * A member key that names a robot under `user:`: an agent of older rows by
209− * its own id, or g1t's upkeep as the workspace or as `g1t`. Events
210− * normalise these (services/events digest), so this is the safety net for
211− * older ones.
209+ * its own id, a workspace (its own token, or g1t's upkeep as it), or `g1t`.
210+ * Events name a workspace this way (services/events digest).
212211 */
213212 export function isRobotKey(key: string): boolean {
214213 return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t";
+7−5
157157
158158 /// An event's actor as a member key, or empty for nobody. Actors are
159159 /// people (`user:<id>`), but some events name an agent by its own id, and
160−/// g1t's own upkeep names the workspace or `g1t`: those read as the agent
161−/// (`agent:<id>`) and as g1t (`user:usr_g1t_agent`), so the digest never
162−/// counts a robot among the people.
160+/// g1t's own upkeep names `g1t`: those read as the agent (`agent:<id>`) and
161+/// as g1t (`user:usr_g1t_agent`). A workspace's own token, and g1t's upkeep
162+/// as the workspace, stay the workspace (`user:wsp_<id>`), never g1t or a
163+/// person; the site counts none of these among the people (apps/web
164+/// home.ts, `isRobotKey`).
163165 fn member_key(actor: Option<&str>) -> String {
164166 match actor {
165167 Some(id) if id.starts_with("agt_") => format!("agent:{id}"),
166− Some(id) if id == "g1t" || id.starts_with("wsp_") => "user:usr_g1t_agent".to_owned(),
168+ Some("g1t") => "user:usr_g1t_agent".to_owned(),
167169 Some(id) if !id.is_empty() => format!("user:{id}"),
168170 _ => String::new(),
169171 }
433435 fn robots_never_count_among_the_people() {
434436 assert_eq!(member_key(Some("usr_ana")), "user:usr_ana");
435437 assert_eq!(member_key(Some("agt_margo")), "agent:agt_margo");
436− assert_eq!(member_key(Some("wsp_acme")), "user:usr_g1t_agent");
438+ assert_eq!(member_key(Some("wsp_acme")), "user:wsp_acme", "a workspace token is the workspace");
437439 assert_eq!(member_key(Some("g1t")), "user:usr_g1t_agent");
438440 assert_eq!(member_key(Some("")), "");
439441 assert_eq!(member_key(None), "");
+6−2
19061906 let repo = match self.authorize_git(&git.path, &viewer, git.service, found).await? {
19071907 Outcome::Ok(repo) => repo,
19081908 refused => {
1909+ let why = match &refused {
1910+ Outcome::Fail(failure) => Some(failure.message.trim_end().to_owned()),
1911+ Outcome::Ok(_) => None,
1912+ };
19091913 let response = git_http::refuse(refused)?;
1910− after.ended(response.status_code(), None);
1914+ after.ended(response.status_code(), why);
19111915 after.spawn(env, ctx);
19121916 return Ok(response);
19131917 }
24702474 /// What a git request leaves for after its answer: its audit entry, with
24712475 /// how the request ended, and what a push changed.
24722476 struct AfterGit {
2473− audit: Option<Box<g1t_contracts::audit::NewAuditEntry>>,
2477+ audit: Option<Box<run_access::GitEntry>>,
24742478 status: u16,
24752479 message: Option<String>,
24762480 push: Option<PushDone>,
+105−19
2525 request: Request,
2626 viewer: Viewer,
2727 /// To be finished with the result and recorded.
28− entry: Option<Box<NewAuditEntry>>,
28+ entry: Option<Box<GitEntry>>,
2929 },
3030 Refused(Response),
3131 }
3232
33+/// A git request's audit entry, before its result is known.
34+pub 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.
43+pub(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.
57+pub(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.
66+pub(crate) fn kept(refused_only: bool, status: u16) -> bool {
67+ !refused_only || status >= 400
68+}
69+
3370 /// The refs a receive-pack request asks to change, deletions included,
3471 /// read from the pkt-lines before the pack.
3572 fn pushed_refs(body: &[u8]) -> Vec<String> {
159196 };
160197
161198 let Some(scope) = run_scope else {
162− // People and workspace tokens: their pushes are recorded; the
199+ // People, workspace tokens and deploy keys: their pushes are
200+ // recorded, and so is a push refused at its first request; the
163201 // repository's own rules decide.
164− let pushing = post && write && git.endpoint == "git-receive-pack";
165− let entry = if pushing {
166− let rule = match user.kind {
167− PrincipalKind::Workspace => "workspace-token",
168− PrincipalKind::Agent => "agent-token",
169− PrincipalKind::User => "person",
170− PrincipalKind::System => "g1t",
171− };
172− Some(Box::new(entry(
173− self.audit_target_of(&git.path, found).await?,
174− &Decision::allow(rule),
175− )))
176− } else {
177− None
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,
178208 };
179209 return Ok(Admitted::Go {
180210 request,
211241 request,
212242 viewer: person,
213243 // Only the requests that move data are worth a line each.
214− entry: post.then(|| Box::new(entry(target, &decision))),
244+ entry: post.then(|| Box::new(GitEntry { entry: entry(target, &decision), refused_only: false })),
215245 })
216246 }
217247
219249 /// HTTP status git was answered with.
220250 pub(crate) async fn finish_git(
221251 &self,
222− entry: Option<Box<NewAuditEntry>>,
252+ entry: Option<Box<GitEntry>>,
223253 status: u16,
224254 message: Option<String>,
225255 ) {
226− let Some(mut entry) = entry.map(|entry| *entry) else {
256+ let Some(GitEntry { mut entry, refused_only }) = entry.map(|entry| *entry) else {
227257 return;
228258 };
259+ if !kept(refused_only, status) {
260+ return;
261+ }
229262 if status == 200 {
230263 entry.result = Some("ok".to_owned());
231264 } else {
241274 }
242275
243276 #[cfg(test)]
277+mod 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)]
244330 mod tests {
245331 use super::pushed_refs;
246332

Comments

Sign in to comment.