neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Audit over the API, and gg audit
d859987 · Gabriel Morell · 2026-10-22 18:12
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−1
| 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 | 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 | 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 | 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 | 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 | 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 | 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