Plan: an audit log for orgs, repositories and the instance

678b57f · Gabriel Morell · 2026-10-20 20:56

3 files +486 -0

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