Plan: an audit log for organizations, repositories and the instance
Status: done — slices 1–12 shipped 2026-10-20 to 2026-10-22. Open questions 4 and 7–14 went with their recommended defaults: rows outlive their actor; an instance admin reading an org’s log leaves no entry; bans are recorded twice; no-ops are skipped; refusals aren’t recorded; append-only isn’t enforced in the database; audit:read is in no bundle; gg audit has no Android screen (cli/README.md says why); no export or forwarding.
Independent of the CI plans. Supersedes the log lines added in 77def2d
(“Secrets: changing a secret or variable leaves a line in the log”) and
closes item 12 of security-review-ci.md (“Missing audit logging for
secret operations”).
Why
An org admin can’t answer “who changed this secret / let this person in / made this repo public / registered this runner?” Today:
- Secret and variable changes write a
Logger.noticewithaudit: true(audit/4inlib/git_gud_web/live/secrets_live/org.exandlib/git_gud_web/live/secrets_live/repo.ex). Only an operator with the logs can read it. It is written after the change, outside any transaction, and for deletes it is written even when nothing was there. GitGud.Events(lib/git_gud/events.ex) is issue and PR history only. It is deliberately best-effort (“never takes part in the caller’s transaction”) and its targets areissue/pull_request. Wrong shape for an audit trail: a dropped audit row is a missing security record.- Bans keep their own trail on the
bansrow (GitGudWeb.ModerationActionsmoduledoc). Nothing else does: membership, invitations, visibility, branch protection, deploy keys, webhooks, runner tokens, registry tokens and job-token grants leave no record of who changed them.
What to reuse from GitGud.Events, and what not
- Reuse: the short-name
target_type/target_idpair, JSONBdatawith string keys (stringify/1), thevalid_kindslist in the schema,GitGudWeb.EventComponents.timeline/1as the model for a component that describes one row per action. - Don’t reuse: best-effort semantics, and keeping the actor out of contexts. Audit rows are written in the change’s own transaction.
Where things are
| Existing audit lines | SecretsLive.Org.audit/4, SecretsLive.Repo.audit/4; test test/git_gud_web/live/secrets_audit_log_test.exs |
| Event history (model only) | lib/git_gud/events.ex, lib/git_gud/events/event.ex, lib/git_gud_web/live/issue_live/history.ex |
| 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 |
| Instance admin | on_mount(:require_admin) in lib/git_gud_web/user_auth.ex (user.is_admin), live_session :admin |
| 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 |
| Scope | lib/git_gud/accounts/scope.ex, built in UserAuth.fetch_current_scope_for_user/2 and mount_current_scope/2 |
| 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 |
| Router | org and repo settings pages live in live_session :authenticated_repo |
| Retention precedent | GitGud.Federation.Workers.Retention; cron in config/config.exs |
| Pagination precedent | OrgLive.Index (/orgs/page/:page, page_window/2) |
| Mobile API auth | GitGudWeb.Api.Mobile.Auth.require_scope/2; scopes in PersonalAccessToken @scopes |
| CLI | cli/src/commands/*.rs, speaks /api/mobile/v1 (cli/README.md: parity with Android) |
1. Schema
Migration create_audit_entries (mix ecto.gen.migration create_audit_entries):
| column | type | notes |
|---|---|---|
id |
bigserial | ordering and keyset cursor |
action |
string, not null | object.verb, e.g. repo_secret.set; must be in the catalog |
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) |
actor_type |
string, not null | user | runner | job | system | federated |
actor_id |
references users, on_delete: :nilify_all |
for user |
actor_ref |
bigint | runner id / workflow job id; no FK (they get deleted) |
actor_label |
string, not null | snapshot: handle, runner name, run #12 / job build, remote actor URL, system |
via |
string | web | api | mobile_api | runner | git | job | cron |
organization_id |
references organizations, on_delete: :nilify_all |
the org at the time, including repo events in org repos |
organization_label |
string | snapshot @handle |
repository_id |
references repositories, on_delete: :nilify_all |
|
repository_label |
string | snapshot owner/name, so a deleted repo still renders |
target_type |
string | secret, variable, membership, invitation, branch_protection, deploy_key, webhook, runner, registry_token, job_token_grant, ban, report, team, repository, organization, … |
target_id |
bigint | no FK (targets get deleted; the label carries it) |
target_label |
string | snapshot: secret name, branch pattern, key title + fingerprint, webhook host, runner name |
subject_user_id |
references users, on_delete: :nilify_all |
the person acted on: invitee, banned/removed/promoted user. Feeds the user log later |
metadata |
map (jsonb), not null, default {} |
allowlisted keys only (below) |
ip |
string | from conn.remote_ip / ClientIp.from_socket/1; nil for system/cron |
user_agent |
string | truncated to 512 |
inserted_at |
utc_datetime_usec | no updated_at: append-only |
Indexes: (organization_id, id DESC), (organization_id, category, id DESC),
(repository_id, id DESC), (actor_id, id DESC), (subject_user_id, id DESC),
(inserted_at) for pruning. Add (organization_id, actor_id, id DESC) only
if EXPLAIN asks for it.
Schema GitGud.Audit.Entry: no update changeset. The context exposes no
update or delete except the pruner.
The catalog and metadata allowlist
GitGud.Audit.Catalog — one map, the only place an action exists:
@actions %{
"repo_secret.set" => %{category: "secrets", target: "secret", keys: ~w()},
"org_variable.deleted" => %{category: "secrets", target: "variable", keys: ~w()},
"repo.visibility_changed" => %{category: "settings", target: "repository", keys: ~w(from to)},
"webhook.created" => %{category: "webhooks", target: "webhook", keys: ~w(host events content_type insecure_ssl)},
...
}
Entry.changeset/2 rejects unknown actions. It drops, without raising,
any metadata key not in keys, and logs a warning naming the key (never the
value). Values are scalars and short lists; strings are truncated to 200
characters. For every action:
- Never a secret value, variable value, token, token hash, webhook secret, deploy key body, TOTP secret or recovery code. Never a hash of one either: a short secret’s hash is a leak.
- URLs reduced to their host. Webhook URLs especially: Slack- and
Discord-style URLs carry a credential in the path. Clone/upstream URLs
go through
Repositories.sanitize_upstream_url/1first. - Free text a moderator wrote (a ban reason) is allowed; it is already shown on the ban list.
2. Recording API
lib/git_gud/audit.ex:
@type actor :: GitGud.Accounts.Scope.t() | GitGud.Audit.Actor.t()
# Run `fun` and record one entry, in one transaction.
# `describe` gets fun's success value and returns entry attrs, or :skip.
Audit.transact(actor, action, fun, describe, opts \\ []) # opts: record: :after | :before
# Inside an existing transaction / Multi.
Audit.insert!(actor, action, attrs)
Audit.multi(multi, name, actor, action, attrs_or_fun)
# Reads
Audit.list(scope_filter, filters, cursor, limit) # {:org, org} | {:repo, repo} | :instance | {:user, user}
Audit.prune(before_datetime, batch \\ 5_000)
transact/5:
- Build and validate the entry changeset before running
fun, with placeholders for result-derived fields. A bad action or a missing label raises in dev/test before anything changes; only a database failure can then abort the insert. Repo.transact(fn -> ... end): runfun.:okbecomes{:ok, :ok},{:ok, x}passes through. On{:error, _}or:errorthe transaction rolls back and no row is written. On success,describe.(x)::skip(a no-op, e.g. a role set to the same role) inserts nothing, otherwise the row is inserted, and if that fails everything rolls back.record: :beforeinserts the row first and runsfunsecond. Required whenfunhas effects outside the database:delete_repository/1runsFile.rm_rfinside its transaction, so a row inserted after it could fail and roll back the database with the files already gone.- Returns exactly what
funreturned, so call sites keep theircase. - After commit, one
Logger.notice("audit: …", audit: true)with action, actor and labels (no metadata), keeping the 77def2d operator log for every action (open question 1), and aPhoenix.PubSub.broadcastof{:audit, id}onaudit:org:<id>/audit:repo:<id>(slice 12). - Never federates.
Auditdoesn’t aliasGitGud.Federation.*; a test asserts noDeliverOutboundjob.
Contexts nest cleanly: Repo.transaction inside Repo.transact joins the
outer one (update_organization_visibility/2, create_organization/2,
accept_invitation/2, do_create_repository/3). Federation publishing in
add_member/3 and create_repository_for_org/3 is an Oban.insert, so it
rolls back too.
GitGud.Audit.Actor covers what isn’t a %Scope{}: Actor.runner(runner, ip),
Actor.job(job, run), Actor.system(:cron | :import | :webhook),
Actor.federated(remote_actor), Actor.api(user, token_id, conn).
Request metadata: add ip and user_agent to GitGud.Accounts.Scope
(Scope.put_request/3), set in fetch_current_scope_for_user/2 from
conn.remote_ip and user-agent, and in mount_current_scope/2 from
ClientIp.from_socket/1 and get_connect_info(socket, :user_agent). The
rest of the app ignores them.
Calls live at the call site that knows the actor (the LiveView handler or
controller action), wrapping the existing context call — the Events
convention, without an actor argument on every context function. Two
exceptions take an audit: option and call Audit.insert!/3 inside their
own transaction: Runners.register_with_token/2 (returns a 3-tuple from
its own transaction) and Organizations.accept_invitation/2 (the actor is
the invitee, the row belongs to the org).
Call sites to instrument
Secrets (slice 2). Replace audit/4 in both files:
| file | handler | context call | action |
|---|---|---|---|
lib/git_gud_web/live/secrets_live/repo.ex |
handle_admin_event("put_secret") |
Secrets.put_repo_secret/3 |
repo_secret.set |
| same | "delete_secret" |
Secrets.delete_repo_secret/2 |
repo_secret.deleted (skip when nothing was deleted: the delete returns {:ok, count}) |
| same | "put_var" / "delete_var" |
put_repo_variable/3 / delete_repo_variable/2 |
repo_variable.set / .deleted |
lib/git_gud_web/live/secrets_live/org.ex |
the same four | put_org_secret/3 etc. |
org_secret.*, org_variable.* |
Members and org (slice 3):
| file | handler | call | action |
|---|---|---|---|
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 |
| same | "revoke_invitation" |
revoke_invitation/1 |
org.invitation_withdrawn |
| same | "set_role" |
set_member_role/3 |
org.member_role_changed {from, to}; skip if unchanged |
| same | "remove_member" |
remove_member_guarded/2 |
org.member_removed {role} (read the role first) |
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 |
lib/git_gud_web/live/org_live/new.ex |
"save" |
create_organization/2 |
org.created {visibility} |
lib/git_gud_web/live/org_live/settings_profile.ex |
"update_profile" |
update_organization_profile/2 |
org.profile_updated {fields: names of changed fields} |
| same | "update_visibility" |
update_organization_visibility/2 |
org.visibility_changed {from, to} |
lib/git_gud_web/live/org_live/show.ex |
"create_team" |
create_team/2 |
team.created |
Repositories (slice 4):
| file | handler | call | action |
|---|---|---|---|
lib/git_gud_web/live/repo_live/new.ex |
"save" |
create_repository/2, create_repository_for_org/3 |
repo.created {visibility, imported_from_host} |
lib/git_gud_web/controllers/api/v1/org_controller.ex |
create_repo/2 |
create_repository_for_org/3 |
repo.created (via api, token_id) |
| same | delete_repo/2 |
delete_repository/1 |
repo.deleted (record: :before) |
lib/git_gud/imports.ex |
the create_repository* calls (~L437/441) |
— | repo.created, actor the import’s owner, via job |
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) |
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) |
| same | "set_sync_mode" |
update_repository/2 |
repo.upstream_settings_changed {mode, auto} |
| same | "overwrite_branch" |
overwrite_branch_from_upstream/2 |
repo.branch_overwritten {branch} (record: :before) |
| same | "delete" |
delete_repository/1 |
repo.deleted (record: :before) |
lib/git_gud_web/live/interaction_policy_live/edit.ex |
"save" |
update_interaction_policy/2 |
repo.interaction_policy_changed {from, to} |
No repository transfer or org deletion exists; their actions are reserved in the catalog with no call sites.
Repo controls (slice 5):
| file | handler | call | action |
|---|---|---|---|
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) |
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 |
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 |
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 |
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 |
Runners and CI (slice 6):
| file | handler | call | action |
|---|---|---|---|
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 |
| same | "create_runner" |
Runners.create_offline/2 |
runner.created {name, labels, via: offline} |
| same | "delete_runner" |
Runners.delete_runner/1 |
runner.deleted |
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 |
lib/mix/tasks/gitgud.runner.create.ex |
run/1 |
create_offline/2 |
runner.created, actor system |
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 |
Moderation (slice 7), all in lib/git_gud_web/live/moderation_actions.ex
(shared by OrgLive.Moderation and RepoLive.Moderation): do_ban/5 →
ban.created {reason, expires_at}, subject the banned user;
do_lift_ban/3 → ban.lifted; do_dismiss_report/3 → report.dismissed;
do_action_report/3 → report.actioned; do_moderate_report/3 →
report.content_moderated {target_type}.
Instance (slice 9, rows with no org or repo):
lib/git_gud_web/live/admin_federation_live/index.ex (block_instance,
unblock_instance, block_actor, unblock_actor, allowlist,
unallowlist, suspend_actor, approve_quarantine, reject_quarantine,
approve_suggested, moderate_content, restore_content);
lib/git_gud_web/live/admin_packages_live/index.ex "save" (Settings.put/3
→ instance_setting.changed {key, from, to}) and "prune_now";
lib/git_gud_web/live/user_live/invites.ex create_invite / revoke_invite.
Deliberately not instrumented: pushes (issue/PR history and CommitBackfill
cover them), package publishes by job tokens (very frequent; revisit),
pins, labels.
3. UI
Routes (path segments; Filters.parse/1 / Filters.to_path/2 keep one canonical path)
/orgs/:handle/settings/audit all
/orgs/:handle/settings/audit/:category one category (or "all")
/orgs/:handle/settings/audit/:category/*refine actor/:handle | repo/:name | before/:id | after/:id, in that order
/r/:owner/:name/settings/audit[/:category[/*refine]] repo view (actor, before, after)
/admin/audit[/:category[/*refine]] instance view (adds org/:handle, scope/instance)
e.g. /orgs/acme/settings/audit/secrets/repo/web/before/12345. An unknown
category or malformed refine push_patches to the canonical path with a
flash; out-of-order segments patch to the canonical order.
Router placement, and why
- Org and repo views in
live_session :authenticated_repo, next to/orgs/:handle/settings/moderationand/r/:owner/:name/settings/moderation: they need a signed-in user (:require_authenticated_userpipeline), and every sibling settings page is there, so moving between them stays in one live_session.RepoAuth :require_readis the floor; the real check is in mount. Declare the literal/settings/auditroutes before any catch-all. /admin/auditinlive_session :admin(on_mount :require_admin), next to/admin/federation.
LiveViews
GitGudWeb.AuditLive.Org(lib/git_gud_web/live/audit_live/org.ex): mount checksOrganizations.org_admin?/2 or user.is_admin, otherwise flash “Admins only.” and navigate to/orgs/:handle(asOrgLive.Moderation). Paging is ahandle_params, so the check runs there too and a demoted admin’s open tab stops paging.<Layouts.app flash={@flash} current_scope={@current_scope}>,org_settings_header current={:audit}(new nav entry) and a link from the settings menu inorg_live/show.ex.- Body: category tabs (
<.link patch>), an actor field (handle; submit →push_patch), a repo<.input type="select">of the org’s repos, andstream(:entries, rows, reset: true)in<div id="audit-entries" phx-update="stream">with ahidden only:blockempty state. “Older”/“Newer” by id (50 per page;before/:idwith the 51st as the next cursor). No page numbers and no counts: the table grows without bound. - Row: time (relative, absolute in
title); actor (avatar + handle, or “runner ci-1”, “system”, a remote actor’s host); a phrase fromGitGudWeb.AuditComponents.describe/1(one clause per catalog action, and a fallback showing the raw action so a new action never crashes the page); target label; repo label (a link whilerepository_idresolves); a<details>with metadata, IP and user agent (open question 3). Row idsaudit-entry-<id>. GitGudWeb.AuditLive.Repo: same component,RepoAdmin.admin?/2in mount andhandle_params,repo_settings_header current={:audit}; showsrepository_id == repo.id(personal repos included).GitGudWeb.AuditLive.Admin: everything, plus an org filter and the instance-only rows.
Who sees what: org view — org admins and instance admins; repo view — the
owner of a personal repo, or an org admin for an org repo (team admins
don’t); admin view — is_admin. Members who aren’t admins see nothing.
Every view needs sudo mode; IP and user agent show only in the admin view.
4. API and CLI (optional, slice 10)
- New PAT scope
audit:readinPersonalAccessToken@scopes, not in the mobile/desktop bundles by default (open question 12). GET /api/mobile/v1/orgs/:org/auditandGET /api/mobile/v1/repos/:owner/:name/auditinGitGudWeb.Api.Mobile.AuditController. Query params, the API’s norm:category,actor,repo,before,limit(≤100). Response{"entries": [...], "next_before": id | null}, each entry{id, action, category, actor: {type, handle, label}, repository, target: {type, label}, metadata, inserted_at}; noip/user_agentunless open question 3 says otherwise. 404 for an org or repo the user can’t see, 403 for a member who isn’t an admin.gg audit --org acme [--repo web] [--category secrets] [--actor alice] [--limit N] [--json]incli/src/commands/audit.rs, plus a model incli/src/api/models.rs.cli/README.mdpromises Android parity: either add a screen or note the exception (open question 13).- Not on
/api/v1(the Gitea/GitHub-compatible surface).
5. Retention
Kept forever by default. The window is application config, set in
config/config.exs (or a deployment’s own config):
# nil keeps every entry; an integer N prunes entries older than N days.
config :git_gud, GitGud.Audit, retention_days: nil
GitGud.Audit.Workers.Prune (queue: :default), @daily in the
config/config.exs crontab, reads it at run time and does nothing while
it is nil. Boot refuses a value that isn’t nil or a positive integer.
Not an admin-editable GitGud.Settings key: shortening the audit trail
shouldn’t be something a compromised admin session can do. Deletes in
batches of 5 000 by id
(DELETE … WHERE id IN (SELECT id … WHERE inserted_at < $1 LIMIT 5000))
until a batch comes back short, so no long lock, and records one
audit.pruned {count, cutoff} instance entry (actor system) when it
deleted anything.
6. User account log (follow-on, slice 11)
Same table: organization_id/repository_id nil, subject_user_id and
actor_id the user, category account. Route
/users/settings/security-log[/*refine] in live_session :require_authenticated_user.
session.login{method: password | magic_link | totp | webauthn | recovery_code}:UserSessionController.create/2and the 2FA completion path (UserAuth.log_in_user/3). Best-effort: a login is a redirect, with no transaction to join.session.login_failed(once perPasswordThrottlewindow per address),session.logout(UserSessionController.delete/2),sudo.entered(reauth_second_factor/2,WebauthnController.sudo_finish).password.changed(update_password/2),email.changed(Accounts.update_user_email/2).two_factor.enabled/.disabled(TwoFactor.enable/3,disable/1viaUserLive.TwoFactorSetup),recovery_codes.regenerated,webauthn.added/.removed/.renamed.pat.created{name, scopes} /pat.revoked(PersonalAccessTokenLive.Index).ssh_key.added{title, fingerprint} /.removed(SshKeyLive.Index).- Org events where the user is the subject (invited, removed, banned) show
in their log too:
subject_user_id == user.id.
Tests
test/git_gud/audit_test.exs- An unknown action raises before
funruns, andfunisn’t called. - Metadata outside the allowlist is dropped; the warning names only the key.
transact/5:{:error, _}→ no row;:ok→ one row;:skip→ no row, change kept.- A failing insert (an
organization_idthat doesn’t exist) rolls the change back: the secret row is absent. record: :before: row present whenfunsucceeds, absent when it fails.- Nested in a context that uses
Repo.transaction(update_organization_visibility/2): one commit, both visible. refute_enqueued(worker: GitGud.Federation.Workers.DeliverOutbound).- Labels survive deletion: the repo is deleted, the row keeps
repository_labelwithrepository_id: nil.
- An unknown action raises before
- Never a secret: put repo and org secrets and variables with value
"S3CR3T-<random>"through the LiveViews; neitherJason.encode!(Repo.all(Entry))nor the rendered audit page contains it. Same for a webhook with secret"WHSEC-<random>"and URLhttps://hooks.example/T000/B000/XYZTOKEN(onlyhooks.examplestored). test/git_gud_web/live/secrets_audit_log_test.exs: also assert the row (actor, action, target_label, and ip viaput_connect_info(conn, peer_data: %{address: {203, 0, 113, 9}, …})).- One LiveView test per call-site group: drive the event, assert exactly
one entry with the expected action/target/metadata keys; none when the
handler refuses (
with_admin/with_org_adminfalse) or the change fails (:last_admin). runner_controller_test.exs: a registration writesrunner.registeredwith actorrunnerand the request IP; a bad token writes nothing.test/git_gud_web/live/audit_live/org_test.exs- Org admins and instance admins see entries; a non-admin member and an
outsider are redirected; an admin of org A can’t read org B by URL; a
demoted admin’s next
handle_paramsredirects. - Category, actor and repo deep links render the filtered stream
(
has_element?(view, "#audit-entry-#{id}")); a malformed refine patches to the canonical path;before/:idboundaries; a deleted repo’s entries render with the snapshot label.
- Org admins and instance admins see entries; a non-admin member and an
outsider are redirected; an admin of org A can’t read org B by URL; a
demoted admin’s next
audit_live/repo_test.exs,audit_live/admin_test.exs: the same permission matrix.test/git_gud/audit/workers/prune_test.exs:retention_days: nilkeeps everything; N days deletes only older rows, across more than one batch; writesaudit.pruned.- API: without
audit:read→ 403; non-admin → 403; invisible org → 404;next_before; noipin the payload.
7. Phasing (each slice ships on its own)
- Substrate. Migration,
Audit.Entry,Audit.Catalog,Audit(transact,insert!,multi,list),Audit.Actor, Scopeip/user_agent,:user_agentconnect_info, prune worker and theretention_daysconfig. No callers, no UI. - Secrets and the org page. Both
SecretsLivemodules (dropaudit/4;Audit’s log line replaces it),AuditLive.Orgwith category tabs and keyset paging, the nav entry,AuditComponents. Closes review item #12. - Membership and org settings. Members, invitations (accept/decline on the dashboard included), org create, profile, visibility, teams.
- Repository lifecycle and settings. Create (LiveView, API, imports,
fork), delete (LiveView, API), settings, visibility, archive,
interaction policy, branch overwrite;
AuditLive.Repoand its nav entry. - Repo controls. Branch protection, deploy keys, webhooks (repo and org), registry tokens, job-token grants.
- Runners and CI. Token mint, register, offline create, delete, the mix task; fork-run approve/reject.
- Moderation. Bans, lifts, report actions.
- Filters. Actor and repo refine segments (slice 2 parses category only).
- Instance.
/admin/auditand the instance-scoped actions. - API and
gg audit(and the Android decision). - User security log (section 6).
- Live updates. PubSub on commit;
stream_insert(..., at: 0)on the first page only.
Done when
mix precommitpasses, with no new compile warnings (mix compile --forcebefore and after).- One commit per slice in the repository’s message style.
- Not pushed without asking.
Decisions (2026-10-20)
- Log line per entry: yes.
Auditkeeps oneLogger.noticeper row after commit (open question 1). - Retention: forever by default, configurable to N days with
config :git_gud, GitGud.Audit, retention_days: Ninconfig.exs(section 5; open question 2). - IP and user agent: instance admins only. Org and repo views, and the
API, never show them;
/admin/auditdoes (open question 3). - Viewing the log requires sudo mode. All three views mount with
{GitGudWeb.UserAuth, :require_sudo_mode}and re-check it inhandle_params(open question 5). - Repo admins who aren’t org admins don’t see an org repo’s log. The
repo view is for repos without an org (the owner) and for org admins;
for an org repo it needs
Organizations.org_admin?/2(open question 6).
Open questions
- Keep a
Logger.noticeper entry (recommended, so log shipping and SIEMs still see it), or drop log lines once the table exists? - Default retention: forever (nil, like federation) or 365 days? Should an org be able to set a shorter window for itself?
- Are IP and user agent shown to org admins, or only to instance admins (members’ personal data)? The same for the API.
- Rows outlive the actor’s account (
actor_labelkept,actor_idnilified). Right, given account deletion and erasure requests? - Require sudo mode to view the log? It’s read-only, but it shows security posture.
- Should repo admins who aren’t org admins see the repo-level log? Should non-admin members see anything?
- Should an instance admin opening an org’s log leave an entry the org can see?
- Bans already carry their own trail. Fine to record them twice (ban row and audit entry) so the org log is complete?
- Skip no-op changes (deleting an absent secret, a save with nothing changed, a role set to the same role)? Recommended: skip.
- Record refused attempts (e.g. a pushed
put_secretthatwith_adminrejected) as best-effort*.deniedentries? - Enforce append-only in the database (a trigger rejecting UPDATE, or a role without UPDATE/DELETE apart from the pruner)?
audit:readin the mobile token bundle, or only when ticked by hand?- Does
gg auditneed an Android screen, given the parity rule incli/README.md? - Export (a JSONL download) or forwarding to a SIEM? Not as a webhook event: that would turn the audit log into a way to send data out.