Audit over the API, and gg audit

d859987 · Gabriel Morell · 2026-10-22 18:12

35 files +555 -39
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −1
@@ -65,7 +65,7 @@ reinvent them.
65 65 - **Invites** — single-use tokens with create / list / revoke per user; UI at `/users/settings/invites`
66 66 - **Org visibility**: `public` / `internal` / `private`. Repos can't be more open than their org; tightening an org cascades to over-public repos in one transaction. Public orgs are accessible to anonymous viewers; private orgs require membership.
67 67 - **Org membership by invitation** at `/orgs/:handle/settings/members` — an admin invites by handle or email and the invitee accepts from their dashboard; nobody is added to an org without agreeing. Withdraw a pending invite, change roles, remove members, with a last-admin guard so an org can never end up admin-less
68 − - **Audit log** at `/orgs/:handle/settings/audit`, per repository at `/r/:owner/:name/settings/audit`, and for instance admins at `/admin/audit` — everything, plus federation blocks, instance settings and registration invites, and the only view with each entry's address and user agent — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments, by category, person and repository (`…/audit/secrets/actor/alice/repo/web/before/<id>`), and a deleted account's or repository's entries can still be picked out by its old name. More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
68 + - **Audit log** at `/orgs/:handle/settings/audit`, per repository at `/r/:owner/:name/settings/audit`, and for instance admins at `/admin/audit` — everything, plus federation blocks, instance settings and registration invites, and the only view with each entry's address and user agent. Also over the mobile API with a token carrying `audit:read` (ticked by hand; in no bundle), and from the CLI as `gg audit` — who changed which CI secret or variable; who was invited, promoted or removed; who created, forked, reconfigured or deleted a repository, and what the org's own settings became; branch protection, deploy keys, webhooks (by host only), registry tokens and job-token grants; runners minted, registered and removed, and fork runs approved; bans and report decisions. Written in the same transaction as the change so a change can't happen unrecorded. Never the value. For org admins and instance admins (and a personal repository's owner), in sudo mode; filters are path segments, by category, person and repository (`…/audit/secrets/actor/alice/repo/web/before/<id>`), and a deleted account's or repository's entries can still be picked out by its old name. More of the org's history joins it slice by slice ([docs/plans/org-audit-log.md](docs/plans/org-audit-log.md)); entries are kept forever unless `GitGud.Audit` `retention_days` is set
69 69 - **Moderation for owners**
70 70 - Org and repo admins get their own queue at `/orgs/:handle/settings/moderation` and `/r/:owner/:name/settings/moderation` — reports about *their* content, which previously only an instance admin could see
71 71 - **Scoped bans** on local users: a repo admin can stop someone acting in their repo, an org admin across every repo the org owns. Neither reaches further; suspending an account instance-wide stays an instance admin's power
modified cli/README.md
+11 −0
@@ -84,8 +84,19 @@ gg wiki list | gg wiki show SLUG
84 84 gg packages list | gg package show FORMAT NAME | gg package usage FORMAT
85 85
86 86 gg dashboard [--refresh SECS] interactive CI dashboard
87 +
88 +gg audit [--category C] [--actor HANDLE] [--limit N] [--before ID]
89 + the repository's audit log: who changed what
90 +gg audit --org ORG [--in NAME] … an organization's, optionally about one repository
87 91 ```
88 92
93 +`gg audit` takes a token with the `audit:read` scope, which no pairing
94 +bundle includes — tick it by hand on the tokens page — and answers only
95 +for the logs its owner may read on the web (an org's admins, a personal
96 +repository's owner). It has no Android counterpart on purpose: reading a
97 +security log is a desk job, not a phone one, so it's the one exception
98 +to keeping the two in step.
99 +
89 100 `--json` on any command prints the server's response verbatim instead of
90 101 a table, which is the intended way to script against it. (`gg ci dispatch
91 102 --json` prints the new run and doesn't watch it.)
modified cli/src/api/mod.rs
+20 −0
@@ -641,6 +641,26 @@ impl Client {
641 641
642 642 // ── labels ───────────────────────────────────────────────────────
643 643
644 + /// An organization's audit log. `query` holds the filters and paging
645 + /// (`category`, `actor`, `repo`, `before`, `limit`).
646 + pub async fn org_audit(
647 + &self,
648 + org: &str,
649 + query: &[(&str, String)],
650 + ) -> Result<Res<AuditResponse>> {
651 + self.get(&["orgs", org, "audit"], query).await
652 + }
653 +
654 + /// A repository's audit log; `query` as for `org_audit`, without `repo`.
655 + pub async fn repo_audit(
656 + &self,
657 + repo: &RepoRef,
658 + query: &[(&str, String)],
659 + ) -> Result<Res<AuditResponse>> {
660 + self.get(&["repos", &repo.owner, &repo.name, "audit"], query)
661 + .await
662 + }
663 +
644 664 pub async fn labels(&self, repo: &RepoRef) -> Result<Res<LabelsResponse>> {
645 665 self.get(&["repos", &repo.owner, &repo.name, "labels"], &[])
646 666 .await
modified cli/src/api/models.rs
+34 −0
@@ -383,6 +383,40 @@ pub struct WorkflowsResponse {
383 383 pub can_dispatch: bool,
384 384 }
385 385
386 +// ── audit ────────────────────────────────────────────────────────────
387 +
388 +#[derive(Debug, Clone, Deserialize)]
389 +pub struct AuditResponse {
390 + #[serde(default)]
391 + pub entries: Vec<AuditEntry>,
392 + /// The id to pass as `before` for the next page; none at the end.
393 + pub next_before: Option<i64>,
394 +}
395 +
396 +#[derive(Debug, Clone, Deserialize)]
397 +pub struct AuditEntry {
398 + pub id: i64,
399 + pub action: String,
400 + /// The phrase the web pages show, without the actor.
401 + pub summary: String,
402 + pub actor: AuditActor,
403 + pub organization: Option<String>,
404 + pub repository: Option<String>,
405 + pub target: AuditTarget,
406 + pub inserted_at: Option<String>,
407 +}
408 +
409 +#[derive(Debug, Clone, Deserialize)]
410 +pub struct AuditActor {
411 + /// A handle, a runner's name, "console", a remote actor's URL.
412 + pub label: String,
413 +}
414 +
415 +#[derive(Debug, Clone, Deserialize)]
416 +pub struct AuditTarget {
417 + pub label: Option<String>,
418 +}
419 +
386 420 // ── issues, pulls, comments ──────────────────────────────────────────
387 421
388 422 #[derive(Debug, Clone, Deserialize)]
modified cli/src/cli.rs
+53 −0
@@ -80,6 +80,11 @@ pub enum Command {
80 80 /// Interactive CI dashboard.
81 81 #[command(visible_aliases = ["tui", "ui"])]
82 82 Dashboard(DashboardArgs),
83 +
84 + /// Who changed what: a repository's audit log, or an organization's
85 + /// with --org. Needs a token with the `audit:read` scope, which no
86 + /// bundle includes.
87 + Audit(AuditArgs),
83 88 }
84 89
85 90 // ── auth ─────────────────────────────────────────────────────────────
@@ -135,6 +140,37 @@ pub struct RepoArgs {
135 140 pub target: Option<String>,
136 141 }
137 142
143 +// ── audit ────────────────────────────────────────────────────────────
144 +
145 +#[derive(Debug, Args)]
146 +pub struct AuditArgs {
147 + /// An organization's log instead of the repository's.
148 + #[arg(long, value_name = "ORG")]
149 + pub org: Option<String>,
150 +
151 + /// With --org: only entries about this one of its repositories.
152 + #[arg(long = "in", value_name = "NAME", requires = "org")]
153 + pub in_repo: Option<String>,
154 +
155 + /// Only this category: secrets, members, settings, repositories,
156 + /// branch_protection, deploy_keys, webhooks, credentials, runners, ci,
157 + /// moderation.
158 + #[arg(long, value_name = "CATEGORY")]
159 + pub category: Option<String>,
160 +
161 + /// Only what this person did, by handle.
162 + #[arg(long, value_name = "HANDLE")]
163 + pub actor: Option<String>,
164 +
165 + /// How many entries (server caps this at 100).
166 + #[arg(long, default_value_t = 50)]
167 + pub limit: u32,
168 +
169 + /// Entries older than this one, by id — the next page.
170 + #[arg(long, value_name = "ID")]
171 + pub before: Option<i64>,
172 +}
173 +
138 174 // ── CI ───────────────────────────────────────────────────────────────
139 175
140 176 #[derive(Debug, Args)]
@@ -687,6 +723,23 @@ mod tests {
687 723 );
688 724 }
689 725
726 + #[test]
727 + fn audit_narrows_an_org_log_to_one_repository_only_with_org() {
728 + let cli = Cli::try_parse_from([
729 + "gg", "audit", "--org", "acme", "--in", "web", "--actor", "alice",
730 + ])
731 + .unwrap();
732 + let Command::Audit(args) = cli.command else {
733 + panic!("not audit");
734 + };
735 + assert_eq!(args.org.as_deref(), Some("acme"));
736 + assert_eq!(args.in_repo.as_deref(), Some("web"));
737 + assert_eq!(args.actor.as_deref(), Some("alice"));
738 + assert_eq!(args.limit, 50);
739 +
740 + assert!(Cli::try_parse_from(["gg", "audit", "--in", "web"]).is_err());
741 + }
742 +
690 743 #[test]
691 744 fn pull_label_takes_repeated_adds_and_removes_but_needs_one() {
692 745 let cli = Cli::try_parse_from([
added cli/src/commands/audit.rs
+84 −0

Click to load diff…

modified cli/src/commands/mod.rs
+1 −0

Click to load diff…

modified cli/src/main.rs
+1 −0

Click to load diff…

modified lib/git_gud/accounts/personal_access_token.ex
+4 −1

Click to load diff…

modified lib/git_gud/audit.ex
+57 −0

Click to load diff…

added lib/git_gud_web/controllers/api/mobile/audit_controller.ex
+113 −0

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/json.ex
+24 −0

Click to load diff…

modified lib/git_gud_web/live/audit_live/org.ex
+1 −2

Click to load diff…

modified lib/git_gud_web/live/audit_live/page.ex
+3 −29

Click to load diff…

modified lib/git_gud_web/live/audit_live/repo.ex
+1 −6

Click to load diff…

modified lib/git_gud_web/router.ex
+3 −0

Click to load diff…

added test/git_gud_web/controllers/api/mobile/mobile_audit_test.exs
+144 −0

Click to load diff…

Parents: 7bb4f3f