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.
8 files+130−340/8 viewed
| 63 | 63 | operation's name too, such as `delete_repo`. See | |
| 64 | 64 | [managing a repository](/guides/managing-repositories/). | |
| 65 | 65 | ||
| 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. | |
| 68 | 70 | ||
| 69 | 71 | ## What an entry says | |
| 70 | 72 | ||
| 71 | 73 | | Field | What it is | | |
| 72 | 74 | | --- | --- | | |
| 73 | 75 | | 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. | | |
| 75 | 77 | | Run | The workflow run whose job's token did it, as `workflow_job`. | | |
| 76 | 78 | | 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. | | |
| 77 | 79 | | Action | The API or MCP operation, such as `create_issue`, or `git.push` and `git.fetch`. | | |
| 78 | 80 | | Target | The repository, the issue or pull request number, and for git the refs it moved. | | |
| 79 | 81 | | 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). | | |
| 81 | 83 | | Result | `ok`, or the reason it failed. | | |
| 82 | 84 | | Request id | The request's id, the same one Cloudflare logs it under. | | |
| 83 | 85 |
| 80 | 80 | assert.equal(ruleLabel("never"), "never allowed for agents (an old entry)"); | |
| 81 | 81 | assert.equal(ruleLabel("run:implement/tools"), "implement run tools"); | |
| 82 | 82 | 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"); | |
| 83 | 85 | }); | |
| 84 | 86 | ||
| 85 | 87 | test("the CSV quotes what it must and keeps formulas as text", () => { |
| 198 | 198 | if (rule === "service" || rule === "repository") return "refused by the repository's rules"; | |
| 199 | 199 | if (rule === "person") return "the person's own access"; | |
| 200 | 200 | if (rule === "workspace-token") return "the workspace token's access"; | |
| 201 | + | if (rule === "deploy-key") return "the deploy key's access"; | |
| 201 | 202 | const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule); | |
| 202 | 203 | if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`; | |
| 203 | 204 | return rule; |
| 363 | 363 | assert.equal(actorKind(MARGO), "agent"); | |
| 364 | 364 | assert.equal(actorKind(G1T), "agent"); | |
| 365 | 365 | 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"); | |
| 367 | 367 | assert.equal(actorKind("user:g1t"), "agent"); | |
| 368 | 368 | assert.equal(actorKind(ANA), "person"); | |
| 369 | 369 | assert.equal(actorKind(""), "person"); |
| 206 | 206 | ||
| 207 | 207 | /** | |
| 208 | 208 | * 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). | |
| 212 | 211 | */ | |
| 213 | 212 | export function isRobotKey(key: string): boolean { | |
| 214 | 213 | return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t"; |
| 157 | 157 | ||
| 158 | 158 | /// An event's actor as a member key, or empty for nobody. Actors are | |
| 159 | 159 | /// 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`). | |
| 163 | 165 | fn member_key(actor: Option<&str>) -> String { | |
| 164 | 166 | match actor { | |
| 165 | 167 | 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(), | |
| 167 | 169 | Some(id) if !id.is_empty() => format!("user:{id}"), | |
| 168 | 170 | _ => String::new(), | |
| 169 | 171 | } | |
| ⋯ | |||
| 433 | 435 | fn robots_never_count_among_the_people() { | |
| 434 | 436 | assert_eq!(member_key(Some("usr_ana")), "user:usr_ana"); | |
| 435 | 437 | 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"); | |
| 437 | 439 | assert_eq!(member_key(Some("g1t")), "user:usr_g1t_agent"); | |
| 438 | 440 | assert_eq!(member_key(Some("")), ""); | |
| 439 | 441 | assert_eq!(member_key(None), ""); | |
| 1906 | 1906 | let repo = match self.authorize_git(&git.path, &viewer, git.service, found).await? { | |
| 1907 | 1907 | Outcome::Ok(repo) => repo, | |
| 1908 | 1908 | refused => { | |
| 1909 | + | let why = match &refused { | |
| 1910 | + | Outcome::Fail(failure) => Some(failure.message.trim_end().to_owned()), | |
| 1911 | + | Outcome::Ok(_) => None, | |
| 1912 | + | }; | |
| 1909 | 1913 | let response = git_http::refuse(refused)?; | |
| 1910 | − | after.ended(response.status_code(), None); | |
| 1914 | + | after.ended(response.status_code(), why); | |
| 1911 | 1915 | after.spawn(env, ctx); | |
| 1912 | 1916 | return Ok(response); | |
| 1913 | 1917 | } | |
| ⋯ | |||
| 2470 | 2474 | /// What a git request leaves for after its answer: its audit entry, with | |
| 2471 | 2475 | /// how the request ended, and what a push changed. | |
| 2472 | 2476 | struct AfterGit { | |
| 2473 | − | audit: Option<Box<g1t_contracts::audit::NewAuditEntry>>, | |
| 2477 | + | audit: Option<Box<run_access::GitEntry>>, | |
| 2474 | 2478 | status: u16, | |
| 2475 | 2479 | message: Option<String>, | |
| 2476 | 2480 | push: Option<PushDone>, | |
| 25 | 25 | request: Request, | |
| 26 | 26 | viewer: Viewer, | |
| 27 | 27 | /// To be finished with the result and recorded. | |
| 28 | − | entry: Option<Box<NewAuditEntry>>, | |
| 28 | + | entry: Option<Box<GitEntry>>, | |
| 29 | 29 | }, | |
| 30 | 30 | Refused(Response), | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 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 | + | ||
| 33 | 70 | /// The refs a receive-pack request asks to change, deletions included, | |
| 34 | 71 | /// read from the pkt-lines before the pack. | |
| 35 | 72 | fn pushed_refs(body: &[u8]) -> Vec<String> { | |
| ⋯ | |||
| 159 | 196 | }; | |
| 160 | 197 | ||
| 161 | 198 | 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 | |
| 163 | 201 | // 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, | |
| 178 | 208 | }; | |
| 179 | 209 | return Ok(Admitted::Go { | |
| 180 | 210 | request, | |
| ⋯ | |||
| 211 | 241 | request, | |
| 212 | 242 | viewer: person, | |
| 213 | 243 | // 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 })), | |
| 215 | 245 | }) | |
| 216 | 246 | } | |
| 217 | 247 | ||
| ⋯ | |||
| 219 | 249 | /// HTTP status git was answered with. | |
| 220 | 250 | pub(crate) async fn finish_git( | |
| 221 | 251 | &self, | |
| 222 | − | entry: Option<Box<NewAuditEntry>>, | |
| 252 | + | entry: Option<Box<GitEntry>>, | |
| 223 | 253 | status: u16, | |
| 224 | 254 | message: Option<String>, | |
| 225 | 255 | ) { | |
| 226 | − | let Some(mut entry) = entry.map(|entry| *entry) else { | |
| 256 | + | let Some(GitEntry { mut entry, refused_only }) = entry.map(|entry| *entry) else { | |
| 227 | 257 | return; | |
| 228 | 258 | }; | |
| 259 | + | if !kept(refused_only, status) { | |
| 260 | + | return; | |
| 261 | + | } | |
| 229 | 262 | if status == 200 { | |
| 230 | 263 | entry.result = Some("ok".to_owned()); | |
| 231 | 264 | } else { | |
| ⋯ | |||
| 241 | 274 | } | |
| 242 | 275 | ||
| 243 | 276 | #[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)] | |
| 244 | 330 | mod tests { | |
| 245 | 331 | use super::pushed_refs; | |
| 246 | 332 | ||
Comments
Sign in to comment.