audit: hash chain, refused writes audited, journal copy !499

merged merged by cmc on 2026-09-28 21:50 UTC · krz/gitbay:audit-refusals-chain into main

20 files changed, +998 −26

Layout: unified · split

.gitbay/wiki/Admin.org +29 −3
@@ -238,12 +238,38 @@ meaningful — an unverified address never produces a =verified= badge.
238238
239239The audit log is the security feed (events are the product feed): every
240240successful mutating command with its argv and source credential (SSH key
241fingerprint or API), registrations, admin actions, force-pushes, and
242auth failures/throttling. Secrets never appear — they travel on stdin,
243never in argv.
241fingerprint or API), every refused one (exit 3 or 4) as =refused
242<command>=, refused pushes as =refused git-receive-pack=, registrations,
243admin actions, force-pushes, and auth failures/throttling. A refusal row
244keeps the flag names and the first positional, not the values.
245Refusals are recorded up to ten a minute per account and 600 a minute
246across the instance; past either, one =refused.throttled= row stands
247for the rest of that minute. The caps bound the embedded listener, the
248web and the API; under =ssh.mode = "system"= each =gitbayd shell=
249connection counts separately. Secrets never appear — they travel on
250stdin, never in argv.
251
252Each row carries the SHA-256 of the row before it. =gitbayd admin audit
253verify= opens the store as other admin commands do, applying pending
254migrations, so run it with the binary that matches the daemon. It
255recomputes the chain and exits 1 naming the first row that was
256edited or whose predecessor was removed. Retention removing the oldest
257rows is not a break. Rows written before the chain existed are counted
258and skipped; when every row is such a row, verify warns and exits 1,
259since clearing the hash columns looks the same. After an upgrade that
260clears with the first new audit row.
261
262Removing the newest rows leaves no break, and neither do rows written
263afterwards under the freed ids. The database cannot show either. The
264daemon logs every row it writes to its journal, outside the database
265(=journalctl -u gitbayd -g 'INFO audit '=), and verify prints the last
266id and hash: compare them with the newest journal line. Rows written by
267host =gitbayd admin= commands, and by =gitbayd shell= when =ssh.mode =
268"system"=, are not copied to the journal.
244269
245270#+begin_src sh
246271gitbayd admin audit [--actor u|-] [--action prefix] [--since 24h|7d|date] [--limit n] [--json]
272gitbayd admin audit verify # check the hash chain; exit 1 names the first bad row
247273ssh git@<host> audit ... # the same, from an admin session
248274ssh git@<host> admin user list [--state active|pending|disabled|admin]
249275ssh git@<host> admin user show <name> # keys, emails, orgs, tokens, sessions
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org +1 −1
@@ -18,7 +18,7 @@ content (as confidential as the repository), *O* operational.
1818| Integrations | =webhooks= (secret), =webhook_deliveries=, =mirrors= (username, token) | C | *plaintext* secrets and tokens |
1919| Notifications | =notifications= (mail queue), =inbox=, =push_devices= (APNs token), =push_queue= | P | device tokens in clear |
2020| Signatures | =commit_signatures=, =settings.key_epoch= | O | verification cache |
21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows |
21| Audit and feed | =audit_log=, =events= | O, P | actor ids, pruned argv, fingerprints and IPs in some audit rows, a hash chain (=prev_hash=, =hash=) |
2222| Dependencies | =dep_checks=, =dep_reports= | O | |
2323
2424Outside the database:
.gitbay/wiki/Architecture/09-Controls.org +2 −2
@@ -68,8 +68,8 @@ chapter names of OWASP ASVS 4.0 where one fits.
6868|---------------------------------------------+----------+------------------------------------------------------------------|
6969| Security-relevant writes audited | in place | every successful mutating command (=control.go=) |
7070| Authentication failures audited | in place | =auth.failed=, =auth.throttled= |
71| Denied attempts audited | gap | refused commands are not recorded (#275) |
72| Audit log tamper resistance | gap | same database, writable by the daemon user (#275) |
71| Denied attempts audited | in place | refused mutating commands and pushes, ten a minute per actor, 600 in all (=internal/control/auditrefusal.go=) |
72| Audit log tamper resistance | partial | hash chain checked by =gitbayd admin audit verify=; every row the daemon writes copied to its journal; the table is writable by the daemon user, and removing the newest rows (or reusing their ids) shows only by comparing verify's last id and hash with the journal |
7373
7474** Communications and integrations (V9, V10, V12)
7575
.gitbay/wiki/Architecture/10-Known-Gaps.org +6 −1
@@ -18,10 +18,15 @@ what the 2026-09-27 review found; remove a row when its issue closes.
1818| #262 | Availability | No limit on concurrent git pack generation | high |
1919| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high |
2020| #274 | Backups | The local backup archive is not encrypted | medium |
21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
2221| #298 | SSRF | =repo import --from= fetches without an address check | medium |
2322| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2423
24* Not filed
25
26| Area | Gap | Severity |
27|-------+-------------------------------------------------------------------------------------------------------------+----------|
28| Audit | Removing the newest audit rows, or writing new rows under their freed ids, is not detectable from the database; only comparing =gitbayd admin audit verify='s last id and hash with the daemon's journal shows it. Rows written by =gitbayd shell= (=ssh.mode = "system"=) and host admin commands have no journal copy, and the refusal caps are per process, so under that mode each connection counts separately | low |
29
2530* Questions an auditor will ask that have no answer yet
2631
2732| Question | Status |
.gitbay/wiki/Threat-Model.org +8
@@ -233,6 +233,14 @@ assume has been checked.
233233- Backups are consistent per the DB-snapshot-first ordering but are not a
234234 single atomic snapshot; a few orphaned git objects are possible and
235235 harmless (see [[Admin]]).
236- The audit log lives in the database the daemon writes, so anyone with
237 the daemon user's access can change it. The hash chain makes an edited
238 or removed row show as a break under =gitbayd admin audit verify=,
239 except at the end: removing the newest rows, and writing new rows
240 under their freed ids, leaves a valid chain. Only comparing verify's
241 last id and hash with the daemon's journal copy shows it, and rows
242 written outside the daemon (=gitbayd shell= under =ssh.mode =
243 "system"=, host =gitbayd admin= commands) have no journal copy.
236244- A global signature-verification epoch over-invalidates the cache on any
237245 trust-input change. Correct, not a leak; a performance tradeoff.
238246- A build's secrets are environment variables inside its container, so
CHANGELOG.org +15
@@ -54,6 +54,21 @@ must add =--scope full=. Existing tokens keep their scope.
5454 daemon acts. *Operators:* deploy with no push in flight, since a
5555 receive-pack started by the old daemon has no token and its
5656 post-receive will be refused by the new one (#282).
57- Audit rows are hash-chained, each carrying the SHA-256 of the one
58 before it (migration 0064), and the daemon logs a copy of every row it
59 writes to its journal. =gitbayd admin audit verify= prints the row
60 count and the last id and hash, and exits 1 naming the first row that
61 was edited or whose predecessor was removed. Removing the newest rows
62 shows only by comparing that last id and hash with the journal (#275).
63- Refused mutating commands (exit 3 or 4) are audited as =refused
64 <command>=, and refused pushes as =refused git-receive-pack=, keeping
65 flag names and the target but no values; ten a minute per account and
66 600 across the instance, counted per process, past which one
67 =refused.throttled= row stands for the rest of the minute. Under
68 =ssh.mode = "system"= each =gitbayd shell= connection counts
69 separately (#275).
70- Audit retention deletes by id, up to the newest row older than the
71 retention, so a clock step back cannot leave a gap in the chain (#275).
5772
5873* v1.36.0 — 2026-09-23
5974
cmd/gitbayd/auditverify.go added +53
@@ -0,0 +1,53 @@
1package main
2
3import (
4 "fmt"
5
6 "github.com/spf13/cobra"
7
8 "gitbay.org/gitbay/internal/config"
9)
10
11// auditVerifyCmd recomputes the audit log's hash chain. A break names
12// the first row that does not match. Rows removed from the end of the
13// log, and rows written after that under the reused ids, leave no
14// break: the last id and hash printed here are what an operator
15// compares with the daemon's journal copy to see either.
16func auditVerifyCmd() *cobra.Command {
17 return &cobra.Command{
18 Use: "verify",
19 Short: "check the audit log's hash chain",
20 Args: cobra.NoArgs,
21 RunE: func(cmd *cobra.Command, args []string) error {
22 cfg, err := config.Load(configPath)
23 if err != nil {
24 return err
25 }
26 st, err := openStore(cfg)
27 if err != nil {
28 return err
29 }
30 defer st.Close()
31 res, err := st.VerifyAuditChain()
32 if err != nil {
33 return err
34 }
35 fmt.Printf("rows %d\nunchained %d\n", res.Rows, res.Unchained)
36 if res.BrokenAt != 0 {
37 return fmt.Errorf("chain broken at row %d: %s", res.BrokenAt, res.Reason)
38 }
39 // Every row unchained is what dropping and re-adding the hash
40 // columns leaves. It is also an upgrade from before migration
41 // 0064 with no row written since; the first new row clears it.
42 if res.Rows > 0 && res.Unchained == res.Rows {
43 return fmt.Errorf("no row carries a hash: the chain columns were cleared, or no audit row has been written since the upgrade")
44 }
45 if res.Last == 0 {
46 fmt.Println("no rows yet")
47 return nil
48 }
49 fmt.Printf("first %d\nlast %d\nlast hash %s\nchain intact\n", res.First, res.Last, res.LastHash)
50 return nil
51 },
52 }
53}
cmd/gitbayd/main.go +7 −1
@@ -140,6 +140,10 @@ func serveCmd() *cobra.Command {
140140 return err
141141 }
142142 defer st.Close()
143 // The daemon's stderr is the service journal: a copy of each
144 // audit row outside the database the daemon can write. Rows
145 // are logged at Info, which the default handler always emits.
146 st.AuditJournal = slog.Default()
143147
144148 // Regenerate hook scripts so a moved binary self-heals, then
145149 // start the hook policy socket.
@@ -406,6 +410,8 @@ func adminCmd() *cobra.Command {
406410 )
407411 configCmd := &cobra.Command{Use: "config", Short: "the configuration in effect"}
408412 configCmd.AddCommand(configShowCmd())
413 auditCmd := hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit")
414 auditCmd.AddCommand(auditVerifyCmd())
409415 admin.AddCommand(
410416 userCmd,
411417 emailCmd,
@@ -414,7 +420,7 @@ func adminCmd() *cobra.Command {
414420 hostCmd("invite --email <address>", "issue a registration invite and email its code", "admin", "invite"),
415421 hostCmd("stats [--json]", "instance statistics: counts and per-repository disk usage", "admin", "stats"),
416422 hostCmd("runners [--json]", "runner accounts: last poll, scope, the build each holds", "admin", "runners"),
417 hostCmd("audit [--limit n] [--json]", "print the security audit log, newest first", "audit"),
423 auditCmd,
418424 backupCmd(),
419425 gcCmd(),
420426 adminMigrateCommitRefsCmd(),
e2e/audit_test.go +47
@@ -2,10 +2,13 @@ package e2e
22
33import (
44 "crypto/rand"
5 "fmt"
56 "os"
67 "path/filepath"
78 "strings"
89 "testing"
10
11 "gitbay.org/gitbay/internal/store"
912)
1013
1114func TestAuditAndHardening(t *testing.T) {
@@ -115,3 +118,47 @@ func TestAuditAndHardening(t *testing.T) {
115118 t.Fatal("throttle audited more than once per window")
116119 }
117120}
121
122// The audit log is a hash chain: gitbayd admin audit verify passes on
123// an untouched log and names the first row that was edited (#275).
124func TestAuditChainVerify(t *testing.T) {
125 t.Parallel()
126 inst := startInstance(t)
127 aliceKey := inst.newKey(t, "alice")
128 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
129 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
130 t.Fatal("repo create failed")
131 }
132 if out := inst.admin(t, "admin", "audit", "verify"); !strings.Contains(out, "chain intact") {
133 t.Fatalf("verify: %s", out)
134 }
135 // The passthrough parent still takes its own flags.
136 if out := inst.admin(t, "admin", "audit", "--limit", "5"); !strings.Contains(out, "repo create") {
137 t.Fatalf("audit --limit: %s", out)
138 }
139
140 st, err := store.Open(filepath.Join(inst.root, "gitbay.db"))
141 if err != nil {
142 t.Fatal(err)
143 }
144 var id int64
145 if err := st.DB.QueryRow("SELECT id FROM audit_log WHERE action = 'cmd repo create'").Scan(&id); err != nil {
146 t.Fatal(err)
147 }
148 if _, err := st.DB.Exec("UPDATE audit_log SET data_json = '{}' WHERE id = ?", id); err != nil {
149 t.Fatal(err)
150 }
151 out := inst.forgedAdminErr(t, "admin", "audit", "verify")
152 if !strings.Contains(out, fmt.Sprintf("chain broken at row %d:", id)) {
153 t.Fatalf("verify after edit: %s", out)
154 }
155
156 // With every hash cleared no row is chained, which is not a pass.
157 if _, err := st.DB.Exec("UPDATE audit_log SET prev_hash = '', hash = ''"); err != nil {
158 t.Fatal(err)
159 }
160 st.Close()
161 if out := inst.forgedAdminErr(t, "admin", "audit", "verify"); !strings.Contains(out, "no row carries a hash") {
162 t.Fatalf("verify with hashes cleared: %s", out)
163 }
164}
internal/control/auditrefusal.go added +95
@@ -0,0 +1,95 @@
1package control
2
3import (
4 "sync"
5 "time"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// refusalsPerMinute bounds audit rows for refused writes per actor. A
11// probe is what these rows record, and a loop of probes must not grow
12// the table without bound.
13const refusalsPerMinute = 10
14
15// refusalsPerMinuteGlobal bounds them across all actors. Registration is
16// open and pending accounts reach Dispatch, so the per-actor bound alone
17// scales with the number of accounts.
18const refusalsPerMinuteGlobal = 600
19
20type refusalLimiter struct {
21 mu sync.Mutex
22 seen map[int64]*refusalWindow
23 global refusalWindow
24}
25
26type refusalWindow struct {
27 start time.Time
28 n int
29}
30
31var refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
32
33// What to write for one refusal.
34const (
35 refusalDrop = iota
36 refusalRecord
37 refusalThrottleActor
38 refusalThrottleGlobal
39)
40
41// allow decides what to write for this refusal. Every row written,
42// throttle rows included, counts against the global bound.
43func (l *refusalLimiter) allow(actor int64, now time.Time) int {
44 l.mu.Lock()
45 defer l.mu.Unlock()
46 if len(l.seen) > 4096 {
47 for k, w := range l.seen {
48 if now.Sub(w.start) >= time.Minute {
49 delete(l.seen, k)
50 }
51 }
52 }
53 w := l.seen[actor]
54 if w == nil || now.Sub(w.start) >= time.Minute {
55 w = &refusalWindow{start: now}
56 l.seen[actor] = w
57 }
58 w.n++
59 want := refusalRecord
60 switch {
61 case w.n == refusalsPerMinute+1:
62 want = refusalThrottleActor
63 case w.n > refusalsPerMinute:
64 return refusalDrop
65 }
66 if now.Sub(l.global.start) >= time.Minute {
67 l.global = refusalWindow{start: now}
68 }
69 l.global.n++
70 switch {
71 case l.global.n <= refusalsPerMinuteGlobal:
72 return want
73 case l.global.n == refusalsPerMinuteGlobal+1:
74 return refusalThrottleGlobal
75 }
76 return refusalDrop
77}
78
79// AuditRefused records a refused attempt to change something. Past the
80// per-actor or the global limit it records one refused.throttled row a
81// minute for that scope and drops the rest. Dispatcher tests run without
82// a store.
83func AuditRefused(st *store.Store, actorID int64, action string, data map[string]any) {
84 if st == nil {
85 return
86 }
87 switch refusals.allow(actorID, time.Now()) {
88 case refusalRecord:
89 st.Audit(actorID, action, data)
90 case refusalThrottleActor:
91 st.Audit(actorID, "refused.throttled", map[string]any{"scope": "actor", "limit_per_minute": refusalsPerMinute})
92 case refusalThrottleGlobal:
93 st.Audit(actorID, "refused.throttled", map[string]any{"scope": "global", "limit_per_minute": refusalsPerMinuteGlobal})
94 }
95}
internal/control/auditrefusal_test.go added +195
@@ -0,0 +1,195 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func TestRefusedWritesAreAudited(t *testing.T) {
14 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
15 st, repo, _ := newQueueTestRepo(t)
16 bobID, err := st.CreateUser("bob", false)
17 if err != nil {
18 t.Fatal(err)
19 }
20 bob := store.User{ID: bobID, Username: "bob"}
21
22 c, _ := pruneCtx(st, t.TempDir(), bob)
23 if code := Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"}); code != protocol.ExitDenied {
24 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
25 }
26 // A refused read is not a write attempt.
27 c, _ = pruneCtx(st, t.TempDir(), bob)
28 if code := Dispatch(c, []string{"audit"}); code != protocol.ExitDenied {
29 t.Fatalf("audit: exit %d", code)
30 }
31 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused", Limit: 10})
32 if err != nil {
33 t.Fatal(err)
34 }
35 if len(got) != 1 || got[0].Action != "refused repo delete" || got[0].Actor != "bob" {
36 t.Fatalf("entries: %+v", got)
37 }
38}
39
40func TestRefusalAuditIsRateLimited(t *testing.T) {
41 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
42 st, repo, _ := newQueueTestRepo(t)
43 bobID, err := st.CreateUser("bob", false)
44 if err != nil {
45 t.Fatal(err)
46 }
47 for range refusalsPerMinute + 5 {
48 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
49 Dispatch(c, []string{"repo", "delete", repo.Path(), "--yes"})
50 }
51 refused, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 100})
52 throttled, _ := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused.throttled", Limit: 100})
53 if len(refused) != refusalsPerMinute || len(throttled) != 1 {
54 t.Fatalf("%d refused rows, %d throttled rows", len(refused), len(throttled))
55 }
56}
57
58// The #257 refusal of a minting command under an expiring credential is
59// a refused write like any other.
60func TestExpiringMintRefusalIsAudited(t *testing.T) {
61 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
62 st, _, _ := newQueueTestRepo(t)
63 bobID, err := st.CreateUser("bob", false)
64 if err != nil {
65 t.Fatal(err)
66 }
67 exp := time.Now().Add(time.Hour)
68 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
69 c.Expires = &exp
70 if code := Dispatch(c, []string{"token", "create", "--name", "x"}); code != protocol.ExitDenied {
71 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
72 }
73 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
74 if err != nil {
75 t.Fatal(err)
76 }
77 if len(got) != 1 || got[0].Action != "refused token create" {
78 t.Fatalf("entries: %+v", got)
79 }
80}
81
82// A gate refuses before parseFlags, so the row must not keep a value
83// glued to its flag, a value that looks like a flag, or a positional
84// past the target.
85func TestRefusalRowKeepsNoValues(t *testing.T) {
86 refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}}
87 st, repo, _ := newQueueTestRepo(t)
88 bobID, err := st.CreateUser("bob", false)
89 if err != nil {
90 t.Fatal(err)
91 }
92 for _, argv := range [][]string{
93 {"issue", "create", repo.Path(), "--body=hunter2"},
94 {"issue", "create", repo.Path(), "--title", "--body=hunter2"},
95 {"repo", "secret", "set", repo.Path(), "NAME", "hunter2"},
96 } {
97 c, _ := pruneCtx(st, t.TempDir(), store.User{ID: bobID, Username: "bob"})
98 c.ReadOnly = true
99 if code := Dispatch(c, argv); code != protocol.ExitDenied {
100 t.Fatalf("%q: exit %d, want %d", argv, code, protocol.ExitDenied)
101 }
102 }
103 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10})
104 if err != nil {
105 t.Fatal(err)
106 }
107 if len(got) != 3 {
108 t.Fatalf("entries: %+v", got)
109 }
110 for _, e := range got {
111 if strings.Contains(e.Data, "hunter2") || strings.Contains(e.Data, "NAME") {
112 t.Errorf("%s kept a value: %s", e.Action, e.Data)
113 }
114 if !strings.Contains(e.Data, repo.Path()) {
115 t.Errorf("%s lost its target: %s", e.Action, e.Data)
116 }
117 }
118}
119
120func TestRefusalArgs(t *testing.T) {
121 for _, tc := range []struct{ in, want []string }{
122 {[]string{"o/r", "NAME", "value"}, []string{"o/r"}},
123 {[]string{"o/r", "--body=x", "--title", "--label=y"}, []string{"o/r", "--body", "--title", "--label"}},
124 {[]string{"o/r", "--title", "t", "--", "a", "b"}, []string{"o/r", "--title"}},
125 } {
126 if got := refusalArgs(tc.in); !slices.Equal(got, tc.want) {
127 t.Errorf("refusalArgs(%q) = %q, want %q", tc.in, got, tc.want)
128 }
129 }
130}
131
132func TestRefusalLimiterWindowResets(t *testing.T) {
133 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
134 now := time.Unix(1_000_000, 0)
135 for i := range refusalsPerMinute {
136 if v := l.allow(1, now); v != refusalRecord {
137 t.Fatalf("refusal %d: %d", i, v)
138 }
139 }
140 if v := l.allow(1, now); v != refusalThrottleActor {
141 t.Fatalf("first past the limit: %d", v)
142 }
143 if v := l.allow(1, now.Add(59*time.Second)); v != refusalDrop {
144 t.Fatalf("second past the limit: %d", v)
145 }
146 if v := l.allow(2, now); v != refusalRecord {
147 t.Fatalf("another actor: %d", v)
148 }
149 if v := l.allow(1, now.Add(time.Minute)); v != refusalRecord {
150 t.Fatalf("next minute: %d", v)
151 }
152}
153
154func TestRefusalLimiterGlobalCeiling(t *testing.T) {
155 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
156 now := time.Unix(1_000_000, 0)
157 var rec, global int
158 for actor := range int64(refusalsPerMinuteGlobal/refusalsPerMinute + 10) {
159 for range refusalsPerMinute {
160 switch l.allow(actor, now) {
161 case refusalRecord:
162 rec++
163 case refusalThrottleGlobal:
164 global++
165 case refusalThrottleActor:
166 t.Fatal("actor throttled under its own limit")
167 }
168 }
169 }
170 if rec != refusalsPerMinuteGlobal || global != 1 {
171 t.Fatalf("%d recorded, %d global throttle rows", rec, global)
172 }
173 if v := l.allow(9999, now.Add(time.Minute)); v != refusalRecord {
174 t.Fatalf("next minute: %d", v)
175 }
176}
177
178func TestRefusalLimiterPrunes(t *testing.T) {
179 l := &refusalLimiter{seen: map[int64]*refusalWindow{}}
180 now := time.Unix(1_000_000, 0)
181 for actor := range int64(4097) {
182 l.seen[actor] = &refusalWindow{start: now, n: 1}
183 }
184 l.allow(5000, now.Add(time.Minute))
185 if len(l.seen) != 1 {
186 t.Fatalf("%d windows after prune, want 1", len(l.seen))
187 }
188 for actor := range int64(4097) {
189 l.seen[actor] = &refusalWindow{start: now.Add(time.Minute), n: 1}
190 }
191 l.allow(6000, now.Add(time.Minute+time.Second))
192 if len(l.seen) != 4099 {
193 t.Fatalf("%d windows, want 4099: a live window was pruned", len(l.seen))
194 }
195}
internal/control/control.go +43 −10
@@ -162,6 +162,23 @@ func Dispatch(c *Ctx, argv []string) int {
162162 args = append(args, a)
163163 }
164164 c.Argv = args
165 code := runChecked(c, cmd, args)
166 if !cmd.ReadOnly {
167 switch code {
168 case protocol.ExitOK:
169 // Every successful mutating command lands in the audit log.
170 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{"argv": auditArgs(args), "source": c.Source})
171 case protocol.ExitDenied, protocol.ExitNotFound:
172 // So does every refused one: probing leaves a trace.
173 AuditRefused(c.Store, c.User.ID, "refused "+joinPath(cmd.Path),
174 map[string]any{"argv": refusalArgs(args), "source": c.Source, "exit": code})
175 }
176 }
177 return code
178}
179
180// runChecked applies the dispatcher's own gates, then runs the command.
181func runChecked(c *Ctx, cmd Command, args []string) int {
165182 // A runner-scoped key reaches the runner protocol and nothing else, so
166183 // the key a CI host holds cannot administer the instance.
167184 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
@@ -195,15 +212,7 @@ func Dispatch(c *Ctx, argv []string) int {
195212 if !cmd.ReadsStdin {
196213 c.Stdin = emptyReader{}
197214 }
198 code := cmd.Run(c, args)
199 // Every successful mutating command lands in the audit log.
200 if code == protocol.ExitOK && !cmd.ReadOnly {
201 c.Store.Audit(c.User.ID, "cmd "+joinPath(cmd.Path), map[string]any{
202 "argv": auditArgs(args),
203 "source": c.Source,
204 })
205 }
206 return code
215 return cmd.Run(c, args)
207216}
208217
209218// auditArgs is argv with flag values dropped. Secrets never reach argv —
@@ -220,7 +229,10 @@ func auditArgs(args []string) []string {
220229 out = append(out, a)
221230 continue
222231 }
223 out = append(out, a)
232 // A gate refuses before parseFlags runs, so a "--name=value"
233 // token reaches here whole; only the name is kept.
234 name, _, _ := strings.Cut(a, "=")
235 out = append(out, name)
224236 // "--" ends flag parsing; everything after it is positional.
225237 if a == "--" {
226238 out = append(out, args[i+1:]...)
@@ -236,6 +248,27 @@ func auditArgs(args []string) []string {
236248 return out
237249}
238250
251// refusalArgs is what a refusal row keeps of argv: the flag names and the
252// first positional, which names the target. A gate refuses before the
253// handler checks its arguments, so later positionals may be anything the
254// caller typed, a value meant for stdin included.
255func refusalArgs(args []string) []string {
256 out := []string{}
257 target := false
258 for _, a := range auditArgs(args) {
259 if a == "--" {
260 break
261 }
262 if strings.HasPrefix(a, "--") {
263 out = append(out, a)
264 } else if !target {
265 out = append(out, a)
266 target = true
267 }
268 }
269 return out
270}
271
239272// pendingAllowed lists what an unverified self-registered account may do.
240273// limitWrites spends one token of the account's write budget, and refuses
241274// with the wait when it is empty. Returns -1 when the command may run.
internal/sshd/refusal_test.go added +68
@@ -0,0 +1,68 @@
1package sshd
2
3import (
4 "bytes"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/control"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// execFixture: alice owns the public alice/app; bob has no grant on it.
16func execFixture(t *testing.T) (config.Config, *store.Store, store.User) {
17 t.Helper()
18 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
19 if err != nil {
20 t.Fatal(err)
21 }
22 t.Cleanup(func() { st.Close() })
23 if err := st.MigrateUp(); err != nil {
24 t.Fatal(err)
25 }
26 alice, err := st.CreateUser("alice", false)
27 if err != nil {
28 t.Fatal(err)
29 }
30 if _, err := st.CreateRepo("user", alice, "app", "public"); err != nil {
31 t.Fatal(err)
32 }
33 bobID, err := st.CreateUser("bob", false)
34 if err != nil {
35 t.Fatal(err)
36 }
37 bob, err := st.UserByID(bobID)
38 if err != nil {
39 t.Fatal(err)
40 }
41 cfg := config.Default()
42 cfg.Server.Root = t.TempDir()
43 return cfg, st, bob
44}
45
46// A refused push leaves one row holding the target, the key and the exit
47// code, whether runGit refused it or the account is not yet active.
48func TestRefusedPushIsAudited(t *testing.T) {
49 for _, pending := range []bool{false, true} {
50 cfg, st, bob := execFixture(t)
51 bob.Pending = pending
52 key := store.SSHKey{Scope: "full", Fingerprint: "SHA256:test"}
53 var out, errOut bytes.Buffer
54 code := Exec(cfg, st, bob, key, control.Term{}, "git-receive-pack alice/app",
55 strings.NewReader(""), &out, &errOut, nil, nil, nil)
56 if code != protocol.ExitDenied {
57 t.Fatalf("pending %v: exit %d: %s", pending, code, errOut.String())
58 }
59 got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused git-receive-pack", Limit: 5})
60 if err != nil || len(got) != 1 || got[0].Actor != "bob" {
61 t.Fatalf("pending %v: entries %+v, %v", pending, got, err)
62 }
63 want := `{"argv":["alice/app"],"exit":4,"source":"SHA256:test"}`
64 if got[0].Data != want {
65 t.Fatalf("pending %v: data %s, want %s", pending, got[0].Data, want)
66 }
67 }
68}
internal/sshd/sshd.go +11 −2
@@ -466,11 +466,20 @@ func Exec(cfg config.Config, st *store.Store, user store.User, key store.SSHKey,
466466 if len(argv) > 0 {
467467 switch argv[0] {
468468 case "git-upload-pack", "git-receive-pack", "git-upload-archive":
469 code := protocol.ExitDenied
469470 if user.Pending {
470471 fmt.Fprintln(stderr, "your account is not active yet: verify your email first")
471 return protocol.ExitDenied
472 } else {
473 code = runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)
474 }
475 // A refused push is a refused write, audited like one. runGit
476 // refuses only with the path as the one argument, so argv[1:]
477 // holds no value beyond the target.
478 if argv[0] == "git-receive-pack" && (code == protocol.ExitDenied || code == protocol.ExitNotFound) {
479 control.AuditRefused(st, user.ID, "refused git-receive-pack",
480 map[string]any{"argv": argv[1:], "source": key.Fingerprint, "exit": code})
472481 }
473 return runGit(cfg, st, user, key.Scope, argv, stdin, stdout, stderr, revoked)
482 return code
474483 case "git-lfs-authenticate":
475484 // Part of the git transport, not the control plane: usable by
476485 // git-scoped and deploy keys, with the transports' access rules.
internal/store/audit.go +128 −5
@@ -1,21 +1,144 @@
11package store
22
3import "encoding/json"
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "time"
10)
411
512// Audit appends to the security feed. Events are the product feed; this
613// records who did what, from where, for an operator. actorID 0 means the
714// host admin (gitbayd admin commands) or an unauthenticated source.
815func (s *Store) Audit(actorID int64, action string, data map[string]any) {
16 raw, err := json.Marshal(data)
17 if err != nil {
18 raw = []byte("{}")
19 }
20 id, createdAt, hash, err := s.appendAudit(actorID, action, string(raw))
21 if s.AuditJournal == nil {
22 return
23 }
24 if err != nil {
25 s.AuditJournal.Error("audit: append", "action", action, "err", err)
26 return
27 }
28 s.AuditJournal.Info("audit", "id", id, "actor", actorID, "action", action,
29 "data", string(raw), "created_at", createdAt, "hash", hash)
30}
31
32// appendAudit writes one row and its chain hash in one transaction. The
33// store begins every transaction IMMEDIATE, so two writers — the daemon
34// and a gitbayd admin command, say — cannot both read the same last
35// hash. The timestamp is taken once the write lock is held, so created_at
36// rises with id unless the clock steps back.
37func (s *Store) appendAudit(actorID int64, action, data string) (int64, string, string, error) {
38 tx, err := s.DB.Begin()
39 if err != nil {
40 return 0, "", "", err
41 }
42 defer tx.Rollback()
43 var prev string
44 err = tx.QueryRow("SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1").Scan(&prev)
45 if err != nil && !errors.Is(err, sql.ErrNoRows) {
46 return 0, "", "", err
47 }
948 var actor any
1049 if actorID != 0 {
1150 actor = actorID
1251 }
13 raw, err := json.Marshal(data)
52 createdAt := fmtTime(time.Now())
53 res, err := tx.Exec(
54 "INSERT INTO audit_log (actor_id, actor_ref, action, data_json, created_at, prev_hash) VALUES (?, ?, ?, ?, ?, ?)",
55 actor, actorID, action, data, createdAt, prev)
1456 if err != nil {
15 raw = []byte("{}")
57 return 0, "", "", err
58 }
59 id, err := res.LastInsertId()
60 if err != nil {
61 return 0, "", "", err
62 }
63 hash := auditHash(prev, id, actorID, action, createdAt, data)
64 if _, err := tx.Exec("UPDATE audit_log SET hash = ? WHERE id = ?", hash, id); err != nil {
65 return 0, "", "", err
66 }
67 return id, createdAt, hash, tx.Commit()
68}
69
70// auditHash covers every column an operator reads, plus the previous
71// row's hash. A JSON array keeps field boundaries unambiguous.
72func auditHash(prev string, id, actor int64, action, createdAt, data string) string {
73 b, _ := json.Marshal([]any{prev, id, actor, action, createdAt, data})
74 sum := sha256.Sum256(b)
75 return hex.EncodeToString(sum[:])
76}
77
78// AuditChain is what VerifyAuditChain found.
79type AuditChain struct {
80 Rows int // rows read
81 Unchained int // rows from before migration 0064, which carry no hash
82 First int64 // first chained row; with no unchained rows before it, its prev_hash is taken as given, since retention may have removed the row it names
83 Last int64
84 LastHash string
85 BrokenAt int64 // 0 when the chain is intact
86 Reason string
87}
88
89// VerifyAuditChain recomputes every row's hash in id order and stops at
90// the first row that does not match. Rows removed from the end of the
91// table cannot be detected from the database, nor can new rows written
92// after that under the reused ids (id is not AUTOINCREMENT); the journal
93// copy is the record that shows either.
94func (s *Store) VerifyAuditChain() (AuditChain, error) {
95 rows, err := s.DB.Query(`SELECT id, actor_id, actor_ref, action, data_json, created_at, prev_hash, hash
96 FROM audit_log ORDER BY id`)
97 if err != nil {
98 return AuditChain{}, err
99 }
100 defer rows.Close()
101 var res AuditChain
102 for rows.Next() {
103 var (
104 id, actor int64
105 actorID sql.NullInt64
106 action, data, createdAt, prev, hash string
107 )
108 if err := rows.Scan(&id, &actorID, &actor, &action, &data, &createdAt, &prev, &hash); err != nil {
109 return res, err
110 }
111 res.Rows++
112 switch {
113 case hash == "" && res.First == 0:
114 res.Unchained++
115 continue
116 case hash == "":
117 res.BrokenAt, res.Reason = id, "row has no hash after the chain began"
118 // The first row appended after migration 0064 names the last
119 // unchained row's empty hash. Retention removes the oldest rows
120 // first, so unchained rows before a chained one with a non-empty
121 // prev_hash had their hashes blanked.
122 case res.First == 0 && res.Unchained > 0 && prev != "":
123 res.BrokenAt, res.Reason = id, "chained rows before it lost their hashes"
124 case res.First != 0 && prev != res.LastHash:
125 res.BrokenAt, res.Reason = id, "previous hash does not match: a row before it was removed or changed"
126 case auditHash(prev, id, actor, action, createdAt, data) != hash:
127 res.BrokenAt, res.Reason = id, "row contents do not match its hash"
128 // actor_id is not hashed; it may only be the actor_ref written
129 // with the row, or NULL once that account is deleted.
130 case actorID.Valid && actorID.Int64 != actor:
131 res.BrokenAt, res.Reason = id, "actor_id does not match the actor the row was written with"
132 }
133 if res.BrokenAt != 0 {
134 return res, nil
135 }
136 if res.First == 0 {
137 res.First = id
138 }
139 res.Last, res.LastHash = id, hash
16140 }
17 s.DB.Exec("INSERT INTO audit_log (actor_id, action, data_json) VALUES (?, ?, ?)",
18 actor, action, string(raw))
141 return res, rows.Err()
19142}
20143
21144type AuditEntry struct {
internal/store/auditchain_test.go added +269
@@ -0,0 +1,269 @@
1package store
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12)
13
14func chainStore(t *testing.T) *Store {
15 t.Helper()
16 s := open(t)
17 if err := s.MigrateUp(); err != nil {
18 t.Fatal(err)
19 }
20 return s
21}
22
23func TestAuditChainIntact(t *testing.T) {
24 s := chainStore(t)
25 s.Audit(0, "a", map[string]any{"n": 1})
26 s.Audit(0, "b", nil)
27 s.Audit(0, "c", map[string]any{"n": 3})
28 res, err := s.VerifyAuditChain()
29 if err != nil {
30 t.Fatal(err)
31 }
32 if res.Rows != 3 || res.BrokenAt != 0 || res.First != 1 || res.Last != 3 || len(res.LastHash) != 64 {
33 t.Fatalf("%+v", res)
34 }
35}
36
37// Concurrent writers each read the last hash and insert in one
38// transaction; none may chain to a predecessor another already took.
39func TestAuditChainConcurrentWriters(t *testing.T) {
40 s := chainStore(t)
41 const writers, each = 8, 25
42 var wg sync.WaitGroup
43 for w := range writers {
44 wg.Add(1)
45 go func() {
46 defer wg.Done()
47 for i := range each {
48 s.Audit(0, fmt.Sprintf("w%d-%d", w, i), nil)
49 }
50 }()
51 }
52 wg.Wait()
53 res, err := s.VerifyAuditChain()
54 if err != nil {
55 t.Fatal(err)
56 }
57 if res.Rows != writers*each || res.Unchained != 0 || res.BrokenAt != 0 {
58 t.Fatalf("%+v", res)
59 }
60}
61
62func TestAuditChainDetectsAnEditedRow(t *testing.T) {
63 for _, set := range []string{
64 "action = 'x'",
65 "created_at = '2020-01-01T00:00:00.000Z'",
66 `data_json = '{"n":2}'`,
67 "actor_ref = 7",
68 } {
69 t.Run(set, func(t *testing.T) {
70 s := chainStore(t)
71 for _, a := range []string{"a", "b", "c"} {
72 s.Audit(0, a, nil)
73 }
74 if _, err := s.DB.Exec("UPDATE audit_log SET " + set + " WHERE id = 2"); err != nil {
75 t.Fatal(err)
76 }
77 res, err := s.VerifyAuditChain()
78 if err != nil {
79 t.Fatal(err)
80 }
81 if res.BrokenAt != 2 || !strings.Contains(res.Reason, "contents") {
82 t.Fatalf("%+v", res)
83 }
84 })
85 }
86}
87
88// Blanking the hash of the oldest chained rows would make them read as
89// rows from before the migration.
90func TestAuditChainDetectsBlankedHashes(t *testing.T) {
91 s := chainStore(t)
92 for _, a := range []string{"a", "b", "c"} {
93 s.Audit(0, a, nil)
94 }
95 if _, err := s.DB.Exec("UPDATE audit_log SET hash = '', action = 'x' WHERE id = 1"); err != nil {
96 t.Fatal(err)
97 }
98 res, err := s.VerifyAuditChain()
99 if err != nil {
100 t.Fatal(err)
101 }
102 if res.BrokenAt != 2 {
103 t.Fatalf("%+v", res)
104 }
105}
106
107// actor_id is not hashed; it must agree with actor_ref or be NULL.
108func TestAuditChainDetectsAChangedActorID(t *testing.T) {
109 s := chainStore(t)
110 uid, err := s.CreateUser("alice", false)
111 if err != nil {
112 t.Fatal(err)
113 }
114 s.Audit(0, "a", nil)
115 s.Audit(0, "b", nil)
116 if _, err := s.DB.Exec("UPDATE audit_log SET actor_id = ? WHERE id = 2", uid); err != nil {
117 t.Fatal(err)
118 }
119 res, err := s.VerifyAuditChain()
120 if err != nil {
121 t.Fatal(err)
122 }
123 if res.BrokenAt != 2 || !strings.Contains(res.Reason, "actor_id") {
124 t.Fatalf("%+v", res)
125 }
126}
127
128// A clock stepped back leaves created_at out of id order; retention
129// still removes a prefix of the table, so the chain stays intact.
130func TestAuditChainSurvivesRetentionWithClockStep(t *testing.T) {
131 s := chainStore(t)
132 for _, a := range []string{"a", "b", "c", "d"} {
133 s.Audit(0, a, nil)
134 }
135 // Rewrite created_at and the hashes as the rows would have been
136 // written: row 2 stamped after row 3, both older than the cutoff.
137 stamps := map[int64]string{
138 1: "2020-01-01T00:00:00.000Z",
139 2: "2020-01-03T00:00:00.000Z",
140 3: "2020-01-02T00:00:00.000Z",
141 4: "2099-01-01T00:00:00.000Z",
142 }
143 prev := ""
144 for id := int64(1); id <= 4; id++ {
145 var action, data string
146 if err := s.DB.QueryRow("SELECT action, data_json FROM audit_log WHERE id = ?", id).Scan(&action, &data); err != nil {
147 t.Fatal(err)
148 }
149 h := auditHash(prev, id, 0, action, stamps[id], data)
150 if _, err := s.DB.Exec("UPDATE audit_log SET created_at = ?, prev_hash = ?, hash = ? WHERE id = ?",
151 stamps[id], prev, h, id); err != nil {
152 t.Fatal(err)
153 }
154 prev = h
155 }
156 if _, err := s.Sweep(Retention{Audit: time.Hour}, mustTime(t, "2020-01-02T12:00:00.000Z")); err != nil {
157 t.Fatal(err)
158 }
159 res, err := s.VerifyAuditChain()
160 if err != nil {
161 t.Fatal(err)
162 }
163 if res.BrokenAt != 0 || res.First != 4 || res.Rows != 1 {
164 t.Fatalf("%+v", res)
165 }
166}
167
168func mustTime(t *testing.T, v string) time.Time {
169 t.Helper()
170 tm, err := time.Parse(time.RFC3339, v)
171 if err != nil {
172 t.Fatal(err)
173 }
174 return tm
175}
176
177func TestAuditChainDetectsARemovedRow(t *testing.T) {
178 s := chainStore(t)
179 for _, a := range []string{"a", "b", "c"} {
180 s.Audit(0, a, nil)
181 }
182 if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 2"); err != nil {
183 t.Fatal(err)
184 }
185 res, err := s.VerifyAuditChain()
186 if err != nil {
187 t.Fatal(err)
188 }
189 if res.BrokenAt != 3 || !strings.Contains(res.Reason, "previous hash") {
190 t.Fatalf("%+v", res)
191 }
192}
193
194// Retention removes the oldest rows, and deleting an account nulls
195// actor_id; neither is tampering.
196func TestAuditChainSurvivesRetentionAndAccountDeletion(t *testing.T) {
197 s := chainStore(t)
198 uid, err := s.CreateUser("alice", false)
199 if err != nil {
200 t.Fatal(err)
201 }
202 s.Audit(0, "a", nil)
203 s.Audit(uid, "b", nil)
204 s.Audit(0, "c", nil)
205 if _, err := s.DB.Exec("DELETE FROM audit_log WHERE id = 1"); err != nil {
206 t.Fatal(err)
207 }
208 if _, err := s.DB.Exec("DELETE FROM users WHERE id = ?", uid); err != nil {
209 t.Fatal(err)
210 }
211 res, err := s.VerifyAuditChain()
212 if err != nil {
213 t.Fatal(err)
214 }
215 if res.BrokenAt != 0 || res.First != 2 || res.Last != 3 {
216 t.Fatalf("%+v", res)
217 }
218}
219
220// Rows written before migration 0064 carry no hash; the chain starts
221// after them, and a hashless row after that start is a break.
222func TestAuditChainLegacyRows(t *testing.T) {
223 s := chainStore(t)
224 if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('legacy')"); err != nil {
225 t.Fatal(err)
226 }
227 s.Audit(0, "a", nil)
228 res, err := s.VerifyAuditChain()
229 if err != nil {
230 t.Fatal(err)
231 }
232 if res.Unchained != 1 || res.BrokenAt != 0 || res.First != 2 {
233 t.Fatalf("%+v", res)
234 }
235 if _, err := s.DB.Exec("INSERT INTO audit_log (action) VALUES ('injected')"); err != nil {
236 t.Fatal(err)
237 }
238 if res, _ = s.VerifyAuditChain(); res.BrokenAt != 3 {
239 t.Fatalf("hashless row after the chain: %+v", res)
240 }
241}
242
243func TestAuditJournal(t *testing.T) {
244 s := chainStore(t)
245 var buf bytes.Buffer
246 s.AuditJournal = slog.New(slog.NewJSONHandler(&buf, nil))
247 s.Audit(0, "cmd repo create", map[string]any{"argv": []string{"a/b"}})
248 lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
249 if len(lines) != 1 {
250 t.Fatalf("journal lines %q", lines)
251 }
252 var got map[string]any
253 if err := json.Unmarshal([]byte(lines[0]), &got); err != nil {
254 t.Fatal(err)
255 }
256 var id float64
257 var data, createdAt, hash string
258 if err := s.DB.QueryRow("SELECT id, data_json, created_at, hash FROM audit_log").Scan(&id, &data, &createdAt, &hash); err != nil {
259 t.Fatal(err)
260 }
261 want := map[string]any{
262 "level": "INFO", "msg": "audit", "id": id, "actor": float64(0), "action": "cmd repo create",
263 "data": data, "created_at": createdAt, "hash": hash,
264 }
265 delete(got, "time")
266 if fmt.Sprint(got) != fmt.Sprint(want) {
267 t.Fatalf("journal line %v, want %v", got, want)
268 }
269}
internal/store/migrations/0064_audit_chain.down.sql added +3
@@ -0,0 +1,3 @@
1ALTER TABLE audit_log DROP COLUMN hash;
2ALTER TABLE audit_log DROP COLUMN prev_hash;
3ALTER TABLE audit_log DROP COLUMN actor_ref;
internal/store/migrations/0064_audit_chain.up.sql added +8
@@ -0,0 +1,8 @@
1-- Each row carries the hash of the row before it. actor_ref is the actor
2-- id as written: actor_id is set to NULL when the account is deleted,
3-- and the hash must not change with it. Rows written before this
4-- migration keep an empty hash; the chain starts after them.
5ALTER TABLE audit_log ADD COLUMN actor_ref INTEGER NOT NULL DEFAULT 0;
6ALTER TABLE audit_log ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
7ALTER TABLE audit_log ADD COLUMN hash TEXT NOT NULL DEFAULT '';
8UPDATE audit_log SET actor_ref = COALESCE(actor_id, 0);
internal/store/retention.go +4 −1
@@ -73,7 +73,10 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) {
7373 where string
7474 keep time.Duration
7575 }{
76 {"audit_log", "created_at < ?", r.Audit},
76 // By id, so a backwards clock step cannot leave a newer row
77 // removed and an older one kept: the audit chain would read
78 // that as tampering.
79 {"audit_log", "id <= (SELECT MAX(id) FROM audit_log WHERE created_at < ?)", r.Audit},
7780 // Only deliveries that have finished: one still being retried is
7881 // live state, however old its first attempt.
7982 {"webhook_deliveries", "created_at < ? AND (delivered_at IS NOT NULL OR failed_at IS NOT NULL)", r.WebhookDeliveries},
internal/store/store.go +6
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "io/fs"
11 "log/slog"
1112 "os"
1213 "sort"
1314 "strconv"
@@ -31,6 +32,11 @@ type Store struct {
3132 // onRevoke runs after each key revocation this process commits.
3233 revokeMu sync.Mutex
3334 onRevoke []func(Revoked)
35
36 // AuditJournal, when set, receives a copy of every audit row. The
37 // daemon sets it to its own logger, whose output the service
38 // journal keeps outside the database.
39 AuditJournal *slog.Logger
3440}
3541
3642// Open opens (creating if needed) the database at path with WAL mode and