neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Plan: an audit log for orgs, repositories and the instance
678b57f · Gabriel Morell · 2026-10-20 20:56
Files changed
added
docs/plans/org-audit-log.md
+486
−0
@@ -0,0 +1,486 @@
| 1 | +# Plan: an audit log for organizations, repositories and the instance | |
| 2 | + | |
| 3 | +Status: **draft — the open questions at the end need answers before slice 2.** | |
| 4 | +Independent of the CI plans. Supersedes the log lines added in 77def2d | |
| 5 | +("Secrets: changing a secret or variable leaves a line in the log") and | |
| 6 | +closes item 12 of `security-review-ci.md` ("Missing audit logging for | |
| 7 | +secret operations"). | |
| 8 | + | |
| 9 | +## Why | |
| 10 | + | |
| 11 | +An org admin can't answer "who changed this secret / let this person in / | |
| 12 | +made this repo public / registered this runner?" Today: | |
| 13 | + | |
| 14 | +- Secret and variable changes write a `Logger.notice` with `audit: true` | |
| 15 | + (`audit/4` in `lib/git_gud_web/live/secrets_live/org.ex` and | |
| 16 | + `lib/git_gud_web/live/secrets_live/repo.ex`). Only an operator with the | |
| 17 | + logs can read it. It is written *after* the change, outside any | |
| 18 | + transaction, and for deletes it is written even when nothing was there. | |
| 19 | +- `GitGud.Events` (`lib/git_gud/events.ex`) is issue and PR history only. | |
| 20 | + It is deliberately best-effort ("never takes part in the caller's | |
| 21 | + transaction") and its targets are `issue`/`pull_request`. Wrong shape for | |
| 22 | + an audit trail: a dropped audit row is a missing security record. | |
| 23 | +- Bans keep their own trail on the `bans` row (`GitGudWeb.ModerationActions` | |
| 24 | + moduledoc). Nothing else does: membership, invitations, visibility, | |
| 25 | + branch protection, deploy keys, webhooks, runner tokens, registry tokens | |
| 26 | + and job-token grants leave no record of who changed them. | |
| 27 | + | |
| 28 | +## What to reuse from `GitGud.Events`, and what not | |
| 29 | + | |
| 30 | +- Reuse: the short-name `target_type`/`target_id` pair, JSONB `data` with | |
| 31 | + string keys (`stringify/1`), the `valid_kinds` list in the schema, | |
| 32 | + `GitGudWeb.EventComponents.timeline/1` as the model for a component that | |
| 33 | + describes one row per action. | |
| 34 | +- Don't reuse: best-effort semantics, and keeping the actor out of | |
| 35 | + contexts. Audit rows are written in the change's own transaction. | |
| 36 | + | |
| 37 | +## Where things are | |
| 38 | + | |
| 39 | +| | | | |
| 40 | +|---|---| | |
| 41 | +| Existing audit lines | `SecretsLive.Org.audit/4`, `SecretsLive.Repo.audit/4`; test `test/git_gud_web/live/secrets_audit_log_test.exs` | | |
| 42 | +| Event history (model only) | `lib/git_gud/events.ex`, `lib/git_gud/events/event.ex`, `lib/git_gud_web/live/issue_live/history.ex` | | |
| 43 | +| Org permission | `Organizations.org_admin?/2` (roles `admin` / `member`); repo permission `Organizations.can?/3`, `GitGudWeb.RepoAdmin.admin?/2`; re-checked per handler via `GitGudWeb.OrgAdmin.with_org_admin/2`, `RepoAdmin.with_admin/2` | | |
| 44 | +| Instance admin | `on_mount(:require_admin)` in `lib/git_gud_web/user_auth.ex` (`user.is_admin`), `live_session :admin` | | |
| 45 | +| Client address | `GitGudWeb.Plugs.ClientIp` (sets `conn.remote_ip`; `from_socket/1` for LiveViews — `:peer_data`/`:x_headers` are already in the `/live` connect_info). User agent: add `:user_agent` to that connect_info | | |
| 46 | +| Scope | `lib/git_gud/accounts/scope.ex`, built in `UserAuth.fetch_current_scope_for_user/2` and `mount_current_scope/2` | | |
| 47 | +| Settings nav | `org_settings_header/1`, `repo_settings_header/1` in `lib/git_gud_web/components/core_components.ex`; org settings links in `lib/git_gud_web/live/org_live/show.ex` | | |
| 48 | +| Router | org and repo settings pages live in `live_session :authenticated_repo` | | |
| 49 | +| Retention precedent | `GitGud.Federation.Workers.Retention`; cron in `config/config.exs`; admin-editable knobs in `GitGud.Settings` (`@keys`) | | |
| 50 | +| Pagination precedent | `OrgLive.Index` (`/orgs/page/:page`, `page_window/2`) | | |
| 51 | +| Mobile API auth | `GitGudWeb.Api.Mobile.Auth.require_scope/2`; scopes in `PersonalAccessToken` `@scopes` | | |
| 52 | +| CLI | `cli/src/commands/*.rs`, speaks `/api/mobile/v1` (`cli/README.md`: parity with Android) | | |
| 53 | + | |
| 54 | +## 1. Schema | |
| 55 | + | |
| 56 | +Migration `create_audit_entries` (`mix ecto.gen.migration create_audit_entries`): | |
| 57 | + | |
| 58 | +| column | type | notes | | |
| 59 | +|---|---|---| | |
| 60 | +| `id` | bigserial | ordering and keyset cursor | | |
| 61 | +| `action` | string, not null | `object.verb`, e.g. `repo_secret.set`; must be in the catalog | | |
| 62 | +| `category` | string, not null | from the catalog, stored for indexed filtering (`secrets`, `members`, `settings`, `repositories`, `branch_protection`, `deploy_keys`, `webhooks`, `credentials`, `runners`, `ci`, `moderation`, `instance`, `account`) | | |
| 63 | +| `actor_type` | string, not null | `user` \| `runner` \| `job` \| `system` \| `federated` | | |
| 64 | +| `actor_id` | references users, `on_delete: :nilify_all` | for `user` | | |
| 65 | +| `actor_ref` | bigint | runner id / workflow job id; no FK (they get deleted) | | |
| 66 | +| `actor_label` | string, not null | snapshot: handle, runner name, `run #12 / job build`, remote actor URL, `system` | | |
| 67 | +| `via` | string | `web` \| `api` \| `mobile_api` \| `runner` \| `git` \| `job` \| `cron` | | |
| 68 | +| `organization_id` | references organizations, `on_delete: :nilify_all` | the org at the time, including repo events in org repos | | |
| 69 | +| `organization_label` | string | snapshot `@handle` | | |
| 70 | +| `repository_id` | references repositories, `on_delete: :nilify_all` | | | |
| 71 | +| `repository_label` | string | snapshot `owner/name`, so a deleted repo still renders | | |
| 72 | +| `target_type` | string | `secret`, `variable`, `membership`, `invitation`, `branch_protection`, `deploy_key`, `webhook`, `runner`, `registry_token`, `job_token_grant`, `ban`, `report`, `team`, `repository`, `organization`, … | | |
| 73 | +| `target_id` | bigint | no FK (targets get deleted; the label carries it) | | |
| 74 | +| `target_label` | string | snapshot: secret name, branch pattern, key title + fingerprint, webhook host, runner name | | |
| 75 | +| `subject_user_id` | references users, `on_delete: :nilify_all` | the person acted *on*: invitee, banned/removed/promoted user. Feeds the user log later | | |
| 76 | +| `metadata` | map (jsonb), not null, default `{}` | allowlisted keys only (below) | | |
| 77 | +| `ip` | string | from `conn.remote_ip` / `ClientIp.from_socket/1`; nil for system/cron | | |
| 78 | +| `user_agent` | string | truncated to 512 | | |
| 79 | +| `inserted_at` | utc_datetime_usec | no `updated_at`: append-only | | |
| 80 | + | |
| 81 | +Indexes: `(organization_id, id DESC)`, `(organization_id, category, id DESC)`, | |
| 82 | +`(repository_id, id DESC)`, `(actor_id, id DESC)`, `(subject_user_id, id DESC)`, | |
| 83 | +`(inserted_at)` for pruning. Add `(organization_id, actor_id, id DESC)` only | |
| 84 | +if `EXPLAIN` asks for it. | |
| 85 | + | |
| 86 | +Schema `GitGud.Audit.Entry`: no update changeset. The context exposes no | |
| 87 | +update or delete except the pruner. | |
| 88 | + | |
| 89 | +### The catalog and metadata allowlist | |
| 90 | + | |
| 91 | +`GitGud.Audit.Catalog` — one map, the only place an action exists: | |
| 92 | + | |
| 93 | +```elixir | |
| 94 | +@actions %{ | |
| 95 | + "repo_secret.set" => %{category: "secrets", target: "secret", keys: ~w()}, | |
| 96 | + "org_variable.deleted" => %{category: "secrets", target: "variable", keys: ~w()}, | |
| 97 | + "repo.visibility_changed" => %{category: "settings", target: "repository", keys: ~w(from to)}, | |
| 98 | + "webhook.created" => %{category: "webhooks", target: "webhook", keys: ~w(host events content_type insecure_ssl)}, | |
| 99 | + ... | |
| 100 | +} | |
| 101 | +``` | |
| 102 | + | |
| 103 | +`Entry.changeset/2` rejects unknown actions. It **drops**, without raising, | |
| 104 | +any metadata key not in `keys`, and logs a warning naming the key (never the | |
| 105 | +value). Values are scalars and short lists; strings are truncated to 200 | |
| 106 | +characters. For every action: | |
| 107 | + | |
| 108 | +- Never a secret value, variable value, token, token hash, webhook secret, | |
| 109 | + deploy key body, TOTP secret or recovery code. Never a hash of one | |
| 110 | + either: a short secret's hash is a leak. | |
| 111 | +- URLs reduced to their host. Webhook URLs especially: Slack- and | |
| 112 | + Discord-style URLs carry a credential in the path. Clone/upstream URLs | |
| 113 | + go through `Repositories.sanitize_upstream_url/1` first. | |
| 114 | +- Free text a moderator wrote (a ban reason) is allowed; it is already | |
| 115 | + shown on the ban list. | |
| 116 | + | |
| 117 | +## 2. Recording API | |
| 118 | + | |
| 119 | +`lib/git_gud/audit.ex`: | |
| 120 | + | |
| 121 | +```elixir | |
| 122 | +@type actor :: GitGud.Accounts.Scope.t() | GitGud.Audit.Actor.t() | |
| 123 | + | |
| 124 | +# Run `fun` and record one entry, in one transaction. | |
| 125 | +# `describe` gets fun's success value and returns entry attrs, or :skip. | |
| 126 | +Audit.transact(actor, action, fun, describe, opts \\ []) # opts: record: :after | :before | |
| 127 | + | |
| 128 | +# Inside an existing transaction / Multi. | |
| 129 | +Audit.insert!(actor, action, attrs) | |
| 130 | +Audit.multi(multi, name, actor, action, attrs_or_fun) | |
| 131 | + | |
| 132 | +# Reads | |
| 133 | +Audit.list(scope_filter, filters, cursor, limit) # {:org, org} | {:repo, repo} | :instance | {:user, user} | |
| 134 | +Audit.prune(before_datetime, batch \\ 5_000) | |
| 135 | +``` | |
| 136 | + | |
| 137 | +`transact/5`: | |
| 138 | + | |
| 139 | +1. Build and validate the entry changeset **before** running `fun`, with | |
| 140 | + placeholders for result-derived fields. A bad action or a missing label | |
| 141 | + raises in dev/test before anything changes; only a database failure can | |
| 142 | + then abort the insert. | |
| 143 | +2. `Repo.transact(fn -> ... end)`: run `fun`. `:ok` becomes `{:ok, :ok}`, | |
| 144 | + `{:ok, x}` passes through. On `{:error, _}` or `:error` the transaction | |
| 145 | + rolls back and no row is written. On success, `describe.(x)`: `:skip` | |
| 146 | + (a no-op, e.g. a role set to the same role) inserts nothing, otherwise | |
| 147 | + the row is inserted, and if that fails everything rolls back. | |
| 148 | +3. `record: :before` inserts the row first and runs `fun` second. Required | |
| 149 | + when `fun` has effects outside the database: `delete_repository/1` runs | |
| 150 | + `File.rm_rf` inside its transaction, so a row inserted after it could | |
| 151 | + fail and roll back the database with the files already gone. | |
| 152 | +4. Returns exactly what `fun` returned, so call sites keep their `case`. | |
| 153 | +5. After commit, one `Logger.notice("audit: …", audit: true)` with action, | |
| 154 | + actor and labels (no metadata), keeping the 77def2d operator log for | |
| 155 | + every action (open question 1), and a `Phoenix.PubSub.broadcast` of | |
| 156 | + `{:audit, id}` on `audit:org:<id>` / `audit:repo:<id>` (slice 12). | |
| 157 | +6. Never federates. `Audit` doesn't alias `GitGud.Federation.*`; a test | |
| 158 | + asserts no `DeliverOutbound` job. | |
| 159 | + | |
| 160 | +Contexts nest cleanly: `Repo.transaction` inside `Repo.transact` joins the | |
| 161 | +outer one (`update_organization_visibility/2`, `create_organization/2`, | |
| 162 | +`accept_invitation/2`, `do_create_repository/3`). Federation publishing in | |
| 163 | +`add_member/3` and `create_repository_for_org/3` is an `Oban.insert`, so it | |
| 164 | +rolls back too. | |
| 165 | + | |
| 166 | +`GitGud.Audit.Actor` covers what isn't a `%Scope{}`: `Actor.runner(runner, ip)`, | |
| 167 | +`Actor.job(job, run)`, `Actor.system(:cron | :import | :webhook)`, | |
| 168 | +`Actor.federated(remote_actor)`, `Actor.api(user, token_id, conn)`. | |
| 169 | + | |
| 170 | +Request metadata: add `ip` and `user_agent` to `GitGud.Accounts.Scope` | |
| 171 | +(`Scope.put_request/3`), set in `fetch_current_scope_for_user/2` from | |
| 172 | +`conn.remote_ip` and `user-agent`, and in `mount_current_scope/2` from | |
| 173 | +`ClientIp.from_socket/1` and `get_connect_info(socket, :user_agent)`. The | |
| 174 | +rest of the app ignores them. | |
| 175 | + | |
| 176 | +Calls live at the call site that knows the actor (the LiveView handler or | |
| 177 | +controller action), wrapping the existing context call — the Events | |
| 178 | +convention, without an actor argument on every context function. Two | |
| 179 | +exceptions take an `audit:` option and call `Audit.insert!/3` inside their | |
| 180 | +own transaction: `Runners.register_with_token/2` (returns a 3-tuple from | |
| 181 | +its own transaction) and `Organizations.accept_invitation/2` (the actor is | |
| 182 | +the invitee, the row belongs to the org). | |
| 183 | + | |
| 184 | +### Call sites to instrument | |
| 185 | + | |
| 186 | +Secrets (slice 2). Replace `audit/4` in both files: | |
| 187 | + | |
| 188 | +| file | handler | context call | action | | |
| 189 | +|---|---|---|---| | |
| 190 | +| `lib/git_gud_web/live/secrets_live/repo.ex` | `handle_admin_event("put_secret")` | `Secrets.put_repo_secret/3` | `repo_secret.set` | | |
| 191 | +| same | `"delete_secret"` | `Secrets.delete_repo_secret/2` | `repo_secret.deleted` (skip when nothing was deleted: the delete returns `{:ok, count}`) | | |
| 192 | +| same | `"put_var"` / `"delete_var"` | `put_repo_variable/3` / `delete_repo_variable/2` | `repo_variable.set` / `.deleted` | | |
| 193 | +| `lib/git_gud_web/live/secrets_live/org.ex` | the same four | `put_org_secret/3` etc. | `org_secret.*`, `org_variable.*` | | |
| 194 | + | |
| 195 | +Members and org (slice 3): | |
| 196 | + | |
| 197 | +| file | handler | call | action | | |
| 198 | +|---|---|---|---| | |
| 199 | +| `lib/git_gud_web/live/org_live/members.ex` | `handle_admin_event("invite_member")` | `Organizations.invite_member/4` | `org.invitation_sent` {role}; skip when an existing pending invitation came back | | |
| 200 | +| same | `"revoke_invitation"` | `revoke_invitation/1` | `org.invitation_withdrawn` | | |
| 201 | +| same | `"set_role"` | `set_member_role/3` | `org.member_role_changed` {from, to}; skip if unchanged | | |
| 202 | +| same | `"remove_member"` | `remove_member_guarded/2` | `org.member_removed` {role} (read the role first) | | |
| 203 | +| `lib/git_gud_web/live/page_live/home.ex` | `respond/4` for `"accept_org_invitation"` / `"decline_org_invitation"` | `accept_invitation/2` (audit opt), `decline_invitation/2` | `org.invitation_accepted` {role} / `org.invitation_declined` | | |
| 204 | +| `lib/git_gud_web/live/org_live/new.ex` | `"save"` | `create_organization/2` | `org.created` {visibility} | | |
| 205 | +| `lib/git_gud_web/live/org_live/settings_profile.ex` | `"update_profile"` | `update_organization_profile/2` | `org.profile_updated` {fields: names of changed fields} | | |
| 206 | +| same | `"update_visibility"` | `update_organization_visibility/2` | `org.visibility_changed` {from, to} | | |
| 207 | +| `lib/git_gud_web/live/org_live/show.ex` | `"create_team"` | `create_team/2` | `team.created` | | |
| 208 | + | |
| 209 | +Repositories (slice 4): | |
| 210 | + | |
| 211 | +| file | handler | call | action | | |
| 212 | +|---|---|---|---| | |
| 213 | +| `lib/git_gud_web/live/repo_live/new.ex` | `"save"` | `create_repository/2`, `create_repository_for_org/3` | `repo.created` {visibility, imported_from_host} | | |
| 214 | +| `lib/git_gud_web/controllers/api/v1/org_controller.ex` | `create_repo/2` | `create_repository_for_org/3` | `repo.created` (via `api`, `token_id`) | | |
| 215 | +| same | `delete_repo/2` | `delete_repository/1` | `repo.deleted` (`record: :before`) | | |
| 216 | +| `lib/git_gud/imports.ex` | the `create_repository*` calls (~L437/441) | — | `repo.created`, actor the import's owner, via `job` | | |
| 217 | +| `lib/git_gud_web/live/repo_live/fork.ex` | `"save"` | `Repositories.fork/4` | `repo.created` {forked_from} on the destination, and `repo.forked` {destination} on the source (a private repo leaving the org) | | |
| 218 | +| `lib/git_gud_web/live/repo_live/settings.ex` | `handle_admin_event("save")` | `update_repository/2` | `repo.settings_updated` {from/to for default_branch, required_approvals, block_merge_on_changes_requested, block_self_approval; description as "changed"}, plus `repo.visibility_changed`, `repo.archived` / `repo.unarchived` when those change (one transaction, several entries) | | |
| 219 | +| same | `"set_sync_mode"` | `update_repository/2` | `repo.upstream_settings_changed` {mode, auto} | | |
| 220 | +| same | `"overwrite_branch"` | `overwrite_branch_from_upstream/2` | `repo.branch_overwritten` {branch} (`record: :before`) | | |
| 221 | +| same | `"delete"` | `delete_repository/1` | `repo.deleted` (`record: :before`) | | |
| 222 | +| `lib/git_gud_web/live/interaction_policy_live/edit.ex` | `"save"` | `update_interaction_policy/2` | `repo.interaction_policy_changed` {from, to} | | |
| 223 | + | |
| 224 | +No repository transfer or org deletion exists; their actions are reserved | |
| 225 | +in the catalog with no call sites. | |
| 226 | + | |
| 227 | +Repo controls (slice 5): | |
| 228 | + | |
| 229 | +| file | handler | call | action | | |
| 230 | +|---|---|---|---| | |
| 231 | +| `lib/git_gud_web/live/branch_protection_live/index.ex` | `handle_admin_event("add")` / `"delete"` | `BranchProtections.create_rule/2` / `delete_rule/1` | `branch_protection.created` {pattern, required_approvals, allow_force_push, allow_deletions, require_status_checks, required_checks} / `.deleted` {pattern}. (`update_rule/2` has no caller yet; instrument it when one appears) | | |
| 232 | +| `lib/git_gud_web/live/deploy_key_live/index.ex` | `"add"` / `"delete"` | `DeployKeys.create_key/2` / `delete_key/1` | `deploy_key.added` {title, fingerprint, read_write} / `.removed` | | |
| 233 | +| `lib/git_gud_web/live/webhook_live/index.ex`, `webhook_live/org.ex` | `"save"`, `"delete"`, `"toggle"` | `Webhooks.create_webhook/2`, `delete_webhook/1`, `update_webhook/2` | `webhook.created` {host, events, content_type, insecure_ssl} / `.deleted` / `.enabled` / `.disabled` | | |
| 234 | +| `lib/git_gud_web/live/registry_token_live/index.ex` | `"create_token"` / `"delete_token"` | `Packages.create_registry_token/3` / `delete_registry_token/2` | `registry_token.created` {name, scopes} / `.revoked` | | |
| 235 | +| `lib/git_gud_web/live/job_token_live/index.ex` | `"create_grant"` / `"delete_grant"` | `JobTokenGrants.create/3` / `delete/2` | `job_token_grant.created` {source, read, dispatch} / `.deleted` | | |
| 236 | + | |
| 237 | +Runners and CI (slice 6): | |
| 238 | + | |
| 239 | +| file | handler | call | action | | |
| 240 | +|---|---|---|---| | |
| 241 | +| `lib/git_gud_web/live/repo_live/settings_runners.ex`, `org_live/settings_runners.ex` | `"mint_token"` | `Runners.mint_registration_token/1` | `runner.registration_token_minted` {expires_at} — never the token | | |
| 242 | +| same | `"create_runner"` | `Runners.create_offline/2` | `runner.created` {name, labels, via: offline} | | |
| 243 | +| same | `"delete_runner"` | `Runners.delete_runner/1` | `runner.deleted` | | |
| 244 | +| `lib/git_gud_web/controllers/runner_controller.ex` | `register/2` | `Runners.register_with_token/2` (audit opt) | `runner.registered` {name, labels, version}; actor `runner`, ip from `conn.remote_ip`; global-token registrations are instance-scoped | | |
| 245 | +| `lib/mix/tasks/gitgud.runner.create.ex` | `run/1` | `create_offline/2` | `runner.created`, actor `system` | | |
| 246 | +| `lib/git_gud_web/live/pr_live/show.ex` (~L281/294), `api/mobile/pull_request_controller.ex` `fork_runs/5` | approve / reject | `Workflows.approve_pull_request_runs/2`, `reject_pull_request_runs/2` | `ci.fork_runs_approved` / `.rejected` {pr number, run count} — approving lets untrusted code run | | |
| 247 | + | |
| 248 | +Moderation (slice 7), all in `lib/git_gud_web/live/moderation_actions.ex` | |
| 249 | +(shared by `OrgLive.Moderation` and `RepoLive.Moderation`): `do_ban/5` → | |
| 250 | +`ban.created` {reason, expires_at}, subject the banned user; | |
| 251 | +`do_lift_ban/3` → `ban.lifted`; `do_dismiss_report/3` → `report.dismissed`; | |
| 252 | +`do_action_report/3` → `report.actioned`; `do_moderate_report/3` → | |
| 253 | +`report.content_moderated` {target_type}. | |
| 254 | + | |
| 255 | +Instance (slice 9, rows with no org or repo): | |
| 256 | +`lib/git_gud_web/live/admin_federation_live/index.ex` (`block_instance`, | |
| 257 | +`unblock_instance`, `block_actor`, `unblock_actor`, `allowlist`, | |
| 258 | +`unallowlist`, `suspend_actor`, `approve_quarantine`, `reject_quarantine`, | |
| 259 | +`approve_suggested`, `moderate_content`, `restore_content`); | |
| 260 | +`lib/git_gud_web/live/admin_packages_live/index.ex` `"save"` (`Settings.put/3` | |
| 261 | +→ `instance_setting.changed` {key, from, to}) and `"prune_now"`; | |
| 262 | +`lib/git_gud_web/live/user_live/invites.ex` `create_invite` / `revoke_invite`. | |
| 263 | + | |
| 264 | +Deliberately not instrumented: pushes (issue/PR history and `CommitBackfill` | |
| 265 | +cover them), package publishes by job tokens (very frequent; revisit), | |
| 266 | +pins, labels. | |
| 267 | + | |
| 268 | +## 3. UI | |
| 269 | + | |
| 270 | +### Routes (path segments; `Filters.parse/1` / `Filters.to_path/2` keep one canonical path) | |
| 271 | + | |
| 272 | +``` | |
| 273 | +/orgs/:handle/settings/audit all | |
| 274 | +/orgs/:handle/settings/audit/:category one category (or "all") | |
| 275 | +/orgs/:handle/settings/audit/:category/*refine actor/:handle | repo/:name | before/:id | after/:id, in that order | |
| 276 | +/r/:owner/:name/settings/audit[/:category[/*refine]] repo view (actor, before, after) | |
| 277 | +/admin/audit[/:category[/*refine]] instance view (adds org/:handle, scope/instance) | |
| 278 | +``` | |
| 279 | + | |
| 280 | +e.g. `/orgs/acme/settings/audit/secrets/repo/web/before/12345`. An unknown | |
| 281 | +category or malformed refine `push_patch`es to the canonical path with a | |
| 282 | +flash; out-of-order segments patch to the canonical order. | |
| 283 | + | |
| 284 | +### Router placement, and why | |
| 285 | + | |
| 286 | +- Org and repo views in `live_session :authenticated_repo`, next to | |
| 287 | + `/orgs/:handle/settings/moderation` and `/r/:owner/:name/settings/moderation`: | |
| 288 | + they need a signed-in user (`:require_authenticated_user` pipeline), and | |
| 289 | + every sibling settings page is there, so moving between them stays in one | |
| 290 | + live_session. `RepoAuth :require_read` is the floor; the real check is in | |
| 291 | + mount. Declare the literal `/settings/audit` routes before any catch-all. | |
| 292 | +- `/admin/audit` in `live_session :admin` (`on_mount :require_admin`), next | |
| 293 | + to `/admin/federation`. | |
| 294 | + | |
| 295 | +### LiveViews | |
| 296 | + | |
| 297 | +- `GitGudWeb.AuditLive.Org` (`lib/git_gud_web/live/audit_live/org.ex`): | |
| 298 | + mount checks `Organizations.org_admin?/2 or user.is_admin`, otherwise | |
| 299 | + flash "Admins only." and navigate to `/orgs/:handle` (as | |
| 300 | + `OrgLive.Moderation`). Paging is a `handle_params`, so the check runs | |
| 301 | + there too and a demoted admin's open tab stops paging. | |
| 302 | + `<Layouts.app flash={@flash} current_scope={@current_scope}>`, | |
| 303 | + `org_settings_header current={:audit}` (new nav entry) and a link from the | |
| 304 | + settings menu in `org_live/show.ex`. | |
| 305 | +- Body: category tabs (`<.link patch>`), an actor field (handle; submit → | |
| 306 | + `push_patch`), a repo `<.input type="select">` of the org's repos, and | |
| 307 | + `stream(:entries, rows, reset: true)` in | |
| 308 | + `<div id="audit-entries" phx-update="stream">` with a `hidden only:block` | |
| 309 | + empty state. "Older"/"Newer" by id (50 per page; `before/:id` with the | |
| 310 | + 51st as the next cursor). No page numbers and no counts: the table grows | |
| 311 | + without bound. | |
| 312 | +- Row: time (relative, absolute in `title`); actor (avatar + handle, or | |
| 313 | + "runner ci-1", "system", a remote actor's host); a phrase from | |
| 314 | + `GitGudWeb.AuditComponents.describe/1` (one clause per catalog action, | |
| 315 | + and a fallback showing the raw action so a new action never crashes the | |
| 316 | + page); target label; repo label (a link while `repository_id` resolves); | |
| 317 | + a `<details>` with metadata, IP and user agent (open question 3). Row ids | |
| 318 | + `audit-entry-<id>`. | |
| 319 | +- `GitGudWeb.AuditLive.Repo`: same component, `RepoAdmin.admin?/2` in mount | |
| 320 | + and `handle_params`, `repo_settings_header current={:audit}`; shows | |
| 321 | + `repository_id == repo.id` (personal repos included). | |
| 322 | +- `GitGudWeb.AuditLive.Admin`: everything, plus an org filter and the | |
| 323 | + instance-only rows. | |
| 324 | + | |
| 325 | +Who sees what: org view — org admins and instance admins; repo view — repo | |
| 326 | +admins (owner, org admins, admin-permission team members); admin view — | |
| 327 | +`is_admin`. Members who aren't admins see nothing. | |
| 328 | + | |
| 329 | +## 4. API and CLI (optional, slice 10) | |
| 330 | + | |
| 331 | +- New PAT scope `audit:read` in `PersonalAccessToken` `@scopes`, not in the | |
| 332 | + mobile/desktop bundles by default (open question 12). | |
| 333 | +- `GET /api/mobile/v1/orgs/:org/audit` and | |
| 334 | + `GET /api/mobile/v1/repos/:owner/:name/audit` in | |
| 335 | + `GitGudWeb.Api.Mobile.AuditController`. Query params, the API's norm: | |
| 336 | + `category`, `actor`, `repo`, `before`, `limit` (≤100). Response | |
| 337 | + `{"entries": [...], "next_before": id | null}`, each entry | |
| 338 | + `{id, action, category, actor: {type, handle, label}, repository, target: {type, label}, metadata, inserted_at}`; | |
| 339 | + no `ip`/`user_agent` unless open question 3 says otherwise. 404 for an | |
| 340 | + org or repo the user can't see, 403 for a member who isn't an admin. | |
| 341 | +- `gg audit --org acme [--repo web] [--category secrets] [--actor alice] [--limit N] [--json]` | |
| 342 | + in `cli/src/commands/audit.rs`, plus a model in `cli/src/api/models.rs`. | |
| 343 | + `cli/README.md` promises Android parity: either add a screen or note the | |
| 344 | + exception (open question 13). | |
| 345 | +- Not on `/api/v1` (the Gitea/GitHub-compatible surface). | |
| 346 | + | |
| 347 | +## 5. Retention | |
| 348 | + | |
| 349 | +`GitGud.Audit.Workers.Prune` (`queue: :default`), `@daily` in the | |
| 350 | +`config/config.exs` crontab. The window is a `GitGud.Settings` key | |
| 351 | +`audit.retention_days` (`type: :integer, default: nil, min: 30, nullable: true`), | |
| 352 | +editable by an admin without a deploy. `nil` keeps everything. Deletes in | |
| 353 | +batches of 5 000 by id | |
| 354 | +(`DELETE … WHERE id IN (SELECT id … WHERE inserted_at < $1 LIMIT 5000)`) | |
| 355 | +until a batch comes back short, so no long lock, and records one | |
| 356 | +`audit.pruned` {count, cutoff} instance entry (actor `system`) when it | |
| 357 | +deleted anything. | |
| 358 | + | |
| 359 | +## 6. User account log (follow-on, slice 11) | |
| 360 | + | |
| 361 | +Same table: `organization_id`/`repository_id` nil, `subject_user_id` and | |
| 362 | +`actor_id` the user, category `account`. Route | |
| 363 | +`/users/settings/security-log[/*refine]` in `live_session :require_authenticated_user`. | |
| 364 | + | |
| 365 | +- `session.login` {method: password | magic_link | totp | webauthn | recovery_code}: | |
| 366 | + `UserSessionController.create/2` and the 2FA completion path | |
| 367 | + (`UserAuth.log_in_user/3`). Best-effort: a login is a redirect, with no | |
| 368 | + transaction to join. | |
| 369 | +- `session.login_failed` (once per `PasswordThrottle` window per address), | |
| 370 | + `session.logout` (`UserSessionController.delete/2`), `sudo.entered` | |
| 371 | + (`reauth_second_factor/2`, `WebauthnController.sudo_finish`). | |
| 372 | +- `password.changed` (`update_password/2`), `email.changed` | |
| 373 | + (`Accounts.update_user_email/2`). | |
| 374 | +- `two_factor.enabled` / `.disabled` (`TwoFactor.enable/3`, `disable/1` via | |
| 375 | + `UserLive.TwoFactorSetup`), `recovery_codes.regenerated`, | |
| 376 | + `webauthn.added` / `.removed` / `.renamed`. | |
| 377 | +- `pat.created` {name, scopes} / `pat.revoked` (`PersonalAccessTokenLive.Index`). | |
| 378 | +- `ssh_key.added` {title, fingerprint} / `.removed` (`SshKeyLive.Index`). | |
| 379 | +- Org events where the user is the subject (invited, removed, banned) show | |
| 380 | + in their log too: `subject_user_id == user.id`. | |
| 381 | + | |
| 382 | +## Tests | |
| 383 | + | |
| 384 | +- `test/git_gud/audit_test.exs` | |
| 385 | + - An unknown action raises before `fun` runs, and `fun` isn't called. | |
| 386 | + - Metadata outside the allowlist is dropped; the warning names only the key. | |
| 387 | + - `transact/5`: `{:error, _}` → no row; `:ok` → one row; `:skip` → no | |
| 388 | + row, change kept. | |
| 389 | + - A failing insert (an `organization_id` that doesn't exist) rolls the | |
| 390 | + change back: the secret row is absent. | |
| 391 | + - `record: :before`: row present when `fun` succeeds, absent when it fails. | |
| 392 | + - Nested in a context that uses `Repo.transaction` | |
| 393 | + (`update_organization_visibility/2`): one commit, both visible. | |
| 394 | + - `refute_enqueued(worker: GitGud.Federation.Workers.DeliverOutbound)`. | |
| 395 | + - Labels survive deletion: the repo is deleted, the row keeps | |
| 396 | + `repository_label` with `repository_id: nil`. | |
| 397 | +- **Never a secret:** put repo and org secrets and variables with value | |
| 398 | + `"S3CR3T-<random>"` through the LiveViews; neither | |
| 399 | + `Jason.encode!(Repo.all(Entry))` nor the rendered audit page contains it. | |
| 400 | + Same for a webhook with secret `"WHSEC-<random>"` and URL | |
| 401 | + `https://hooks.example/T000/B000/XYZTOKEN` (only `hooks.example` stored). | |
| 402 | +- `test/git_gud_web/live/secrets_audit_log_test.exs`: also assert the row | |
| 403 | + (actor, action, target_label, and ip via `put_connect_info(conn, peer_data: %{address: {203, 0, 113, 9}, …})`). | |
| 404 | +- One LiveView test per call-site group: drive the event, assert exactly | |
| 405 | + one entry with the expected action/target/metadata keys; none when the | |
| 406 | + handler refuses (`with_admin` / `with_org_admin` false) or the change | |
| 407 | + fails (`:last_admin`). | |
| 408 | +- `runner_controller_test.exs`: a registration writes `runner.registered` | |
| 409 | + with actor `runner` and the request IP; a bad token writes nothing. | |
| 410 | +- `test/git_gud_web/live/audit_live/org_test.exs` | |
| 411 | + - Org admins and instance admins see entries; a non-admin member and an | |
| 412 | + outsider are redirected; an admin of org A can't read org B by URL; a | |
| 413 | + demoted admin's next `handle_params` redirects. | |
| 414 | + - Category, actor and repo deep links render the filtered stream | |
| 415 | + (`has_element?(view, "#audit-entry-#{id}")`); a malformed refine | |
| 416 | + patches to the canonical path; `before/:id` boundaries; a deleted | |
| 417 | + repo's entries render with the snapshot label. | |
| 418 | +- `audit_live/repo_test.exs`, `audit_live/admin_test.exs`: the same | |
| 419 | + permission matrix. | |
| 420 | +- `test/git_gud/audit/workers/prune_test.exs`: nil keeps everything; N days | |
| 421 | + deletes only older rows, across more than one batch; writes `audit.pruned`. | |
| 422 | +- API: without `audit:read` → 403; non-admin → 403; invisible org → 404; | |
| 423 | + `next_before`; no `ip` in the payload. | |
| 424 | + | |
| 425 | +## 7. Phasing (each slice ships on its own) | |
| 426 | + | |
| 427 | +1. **Substrate.** Migration, `Audit.Entry`, `Audit.Catalog`, `Audit` | |
| 428 | + (`transact`, `insert!`, `multi`, `list`), `Audit.Actor`, Scope | |
| 429 | + `ip`/`user_agent`, `:user_agent` connect_info, prune worker and | |
| 430 | + `audit.retention_days`. No callers, no UI. | |
| 431 | +2. **Secrets and the org page.** Both `SecretsLive` modules (drop `audit/4`; | |
| 432 | + `Audit`'s log line replaces it), `AuditLive.Org` with category tabs and | |
| 433 | + keyset paging, the nav entry, `AuditComponents`. Closes review item #12. | |
| 434 | +3. **Membership and org settings.** Members, invitations (accept/decline on | |
| 435 | + the dashboard included), org create, profile, visibility, teams. | |
| 436 | +4. **Repository lifecycle and settings.** Create (LiveView, API, imports, | |
| 437 | + fork), delete (LiveView, API), settings, visibility, archive, | |
| 438 | + interaction policy, branch overwrite; `AuditLive.Repo` and its nav entry. | |
| 439 | +5. **Repo controls.** Branch protection, deploy keys, webhooks (repo and | |
| 440 | + org), registry tokens, job-token grants. | |
| 441 | +6. **Runners and CI.** Token mint, register, offline create, delete, the | |
| 442 | + mix task; fork-run approve/reject. | |
| 443 | +7. **Moderation.** Bans, lifts, report actions. | |
| 444 | +8. **Filters.** Actor and repo refine segments (slice 2 parses category only). | |
| 445 | +9. **Instance.** `/admin/audit` and the instance-scoped actions. | |
| 446 | +10. **API and `gg audit`** (and the Android decision). | |
| 447 | +11. **User security log** (section 6). | |
| 448 | +12. **Live updates.** PubSub on commit; `stream_insert(..., at: 0)` on the | |
| 449 | + first page only. | |
| 450 | + | |
| 451 | +## Done when | |
| 452 | + | |
| 453 | +- `mix precommit` passes, with no new compile warnings | |
| 454 | + (`mix compile --force` before and after). | |
| 455 | +- One commit per slice in the repository's message style. | |
| 456 | +- **Not pushed** without asking. | |
| 457 | + | |
| 458 | +## Open questions | |
| 459 | + | |
| 460 | +1. Keep a `Logger.notice` per entry (recommended, so log shipping and SIEMs | |
| 461 | + still see it), or drop log lines once the table exists? | |
| 462 | +2. Default retention: forever (nil, like federation) or 365 days? Should an | |
| 463 | + org be able to set a shorter window for itself? | |
| 464 | +3. Are IP and user agent shown to org admins, or only to instance admins | |
| 465 | + (members' personal data)? The same for the API. | |
| 466 | +4. Rows outlive the actor's account (`actor_label` kept, `actor_id` | |
| 467 | + nilified). Right, given account deletion and erasure requests? | |
| 468 | +5. Require sudo mode to view the log? It's read-only, but it shows security | |
| 469 | + posture. | |
| 470 | +6. Should repo admins who aren't org admins see the repo-level log? Should | |
| 471 | + non-admin members see anything? | |
| 472 | +7. Should an instance admin opening an org's log leave an entry the org can | |
| 473 | + see? | |
| 474 | +8. Bans already carry their own trail. Fine to record them twice (ban row | |
| 475 | + and audit entry) so the org log is complete? | |
| 476 | +9. Skip no-op changes (deleting an absent secret, a save with nothing | |
| 477 | + changed, a role set to the same role)? Recommended: skip. | |
| 478 | +10. Record refused attempts (e.g. a pushed `put_secret` that `with_admin` | |
| 479 | + rejected) as best-effort `*.denied` entries? | |
| 480 | +11. Enforce append-only in the database (a trigger rejecting UPDATE, or a | |
| 481 | + role without UPDATE/DELETE apart from the pruner)? | |
| 482 | +12. `audit:read` in the mobile token bundle, or only when ticked by hand? | |
| 483 | +13. Does `gg audit` need an Android screen, given the parity rule in | |
| 484 | + `cli/README.md`? | |
| 485 | +14. Export (a JSONL download) or forwarding to a SIEM? Not as a webhook | |
| 486 | + event: that would turn the audit log into a way to send data out. |
Parents: a81156c