29.5 KiB · text History 678b57f

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

Status: draft — the open questions at the end need answers before slice 2. 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.notice with audit: true (audit/4 in lib/git_gud_web/live/secrets_live/org.ex and lib/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 are issue/pull_request. Wrong shape for an audit trail: a dropped audit row is a missing security record.
  • Bans keep their own trail on the bans row (GitGudWeb.ModerationActions moduledoc). 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_id pair, JSONB data with string keys (stringify/1), the valid_kinds list in the schema, GitGudWeb.EventComponents.timeline/1 as 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; admin-editable knobs in GitGud.Settings (@keys)
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/1 first.
  • 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:

  1. 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.
  2. Repo.transact(fn -> ... end): run fun. :ok becomes {:ok, :ok}, {:ok, x} passes through. On {:error, _} or :error the 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.
  3. record: :before inserts the row first and runs fun second. Required when fun has effects outside the database: delete_repository/1 runs File.rm_rf inside its transaction, so a row inserted after it could fail and roll back the database with the files already gone.
  4. Returns exactly what fun returned, so call sites keep their case.
  5. 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 a Phoenix.PubSub.broadcast of {:audit, id} on audit:org:<id> / audit:repo:<id> (slice 12).
  6. Never federates. Audit doesn’t alias GitGud.Federation.*; a test asserts no DeliverOutbound job.

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/moderation and /r/:owner/:name/settings/moderation: they need a signed-in user (:require_authenticated_user pipeline), and every sibling settings page is there, so moving between them stays in one live_session. RepoAuth :require_read is the floor; the real check is in mount. Declare the literal /settings/audit routes before any catch-all.
  • /admin/audit in live_session :admin (on_mount :require_admin), next to /admin/federation.

LiveViews

  • GitGudWeb.AuditLive.Org (lib/git_gud_web/live/audit_live/org.ex): mount checks Organizations.org_admin?/2 or user.is_admin, otherwise flash “Admins only.” and navigate to /orgs/:handle (as OrgLive.Moderation). Paging is a handle_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 in org_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, and stream(:entries, rows, reset: true) in <div id="audit-entries" phx-update="stream"> with a hidden only:block empty state. “Older”/“Newer” by id (50 per page; before/:id with 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 from GitGudWeb.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 while repository_id resolves); a <details> with metadata, IP and user agent (open question 3). Row ids audit-entry-<id>.
  • GitGudWeb.AuditLive.Repo: same component, RepoAdmin.admin?/2 in mount and handle_params, repo_settings_header current={:audit}; shows repository_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 — repo admins (owner, org admins, admin-permission team members); admin view — is_admin. Members who aren’t admins see nothing.

4. API and CLI (optional, slice 10)

  • New PAT scope audit:read in PersonalAccessToken @scopes, not in the mobile/desktop bundles by default (open question 12).
  • GET /api/mobile/v1/orgs/:org/audit and GET /api/mobile/v1/repos/:owner/:name/audit in GitGudWeb.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}; no ip/user_agent unless 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] in cli/src/commands/audit.rs, plus a model in cli/src/api/models.rs. cli/README.md promises Android parity: either add a screen or note the exception (open question 13).
  • Not on /api/v1 (the Gitea/GitHub-compatible surface).

5. Retention

GitGud.Audit.Workers.Prune (queue: :default), @daily in the config/config.exs crontab. The window is a GitGud.Settings key audit.retention_days (type: :integer, default: nil, min: 30, nullable: true), editable by an admin without a deploy. nil keeps everything. 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/2 and 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 per PasswordThrottle window 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/1 via UserLive.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 fun runs, and fun isn’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_id that doesn’t exist) rolls the change back: the secret row is absent.
    • record: :before: row present when fun succeeds, 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_label with repository_id: nil.
  • Never a secret: put repo and org secrets and variables with value "S3CR3T-<random>" through the LiveViews; neither Jason.encode!(Repo.all(Entry)) nor the rendered audit page contains it. Same for a webhook with secret "WHSEC-<random>" and URL https://hooks.example/T000/B000/XYZTOKEN (only hooks.example stored).
  • test/git_gud_web/live/secrets_audit_log_test.exs: also assert the row (actor, action, target_label, and ip via put_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_admin false) or the change fails (:last_admin).
  • runner_controller_test.exs: a registration writes runner.registered with actor runner and 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_params redirects.
    • 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/:id boundaries; a deleted repo’s entries render with the snapshot label.
  • audit_live/repo_test.exs, audit_live/admin_test.exs: the same permission matrix.
  • test/git_gud/audit/workers/prune_test.exs: nil keeps everything; N days deletes only older rows, across more than one batch; writes audit.pruned.
  • API: without audit:read → 403; non-admin → 403; invisible org → 404; next_before; no ip in the payload.

7. Phasing (each slice ships on its own)

  1. Substrate. Migration, Audit.Entry, Audit.Catalog, Audit (transact, insert!, multi, list), Audit.Actor, Scope ip/user_agent, :user_agent connect_info, prune worker and audit.retention_days. No callers, no UI.
  2. Secrets and the org page. Both SecretsLive modules (drop audit/4; Audit’s log line replaces it), AuditLive.Org with category tabs and keyset paging, the nav entry, AuditComponents. Closes review item #12.
  3. Membership and org settings. Members, invitations (accept/decline on the dashboard included), org create, profile, visibility, teams.
  4. Repository lifecycle and settings. Create (LiveView, API, imports, fork), delete (LiveView, API), settings, visibility, archive, interaction policy, branch overwrite; AuditLive.Repo and its nav entry.
  5. Repo controls. Branch protection, deploy keys, webhooks (repo and org), registry tokens, job-token grants.
  6. Runners and CI. Token mint, register, offline create, delete, the mix task; fork-run approve/reject.
  7. Moderation. Bans, lifts, report actions.
  8. Filters. Actor and repo refine segments (slice 2 parses category only).
  9. Instance. /admin/audit and the instance-scoped actions.
  10. API and gg audit (and the Android decision).
  11. User security log (section 6).
  12. Live updates. PubSub on commit; stream_insert(..., at: 0) on the first page only.

Done when

  • mix precommit passes, with no new compile warnings (mix compile --force before and after).
  • One commit per slice in the repository’s message style.
  • Not pushed without asking.

Open questions

  1. Keep a Logger.notice per entry (recommended, so log shipping and SIEMs still see it), or drop log lines once the table exists?
  2. Default retention: forever (nil, like federation) or 365 days? Should an org be able to set a shorter window for itself?
  3. Are IP and user agent shown to org admins, or only to instance admins (members’ personal data)? The same for the API.
  4. Rows outlive the actor’s account (actor_label kept, actor_id nilified). Right, given account deletion and erasure requests?
  5. Require sudo mode to view the log? It’s read-only, but it shows security posture.
  6. Should repo admins who aren’t org admins see the repo-level log? Should non-admin members see anything?
  7. Should an instance admin opening an org’s log leave an entry the org can see?
  8. Bans already carry their own trail. Fine to record them twice (ban row and audit entry) so the org log is complete?
  9. Skip no-op changes (deleting an absent secret, a save with nothing changed, a role set to the same role)? Recommended: skip.
  10. Record refused attempts (e.g. a pushed put_secret that with_admin rejected) as best-effort *.denied entries?
  11. Enforce append-only in the database (a trigger rejecting UPDATE, or a role without UPDATE/DELETE apart from the pruner)?
  12. audit:read in the mobile token bundle, or only when ticked by hand?
  13. Does gg audit need an Android screen, given the parity rule in cli/README.md?
  14. 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.