internal/store/audit.go
198 lines · 6656 bytes
8 symbols in this file
1package store
2
3import (
4 "crypto/sha256"
5 "database/sql"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "time"
10)
11
12// Audit appends to the security feed. Events are the product feed; this
13// records who did what, from where, for an operator. actorID 0 means the
14// host admin (gitbayd admin commands) or an unauthenticated source.
15func (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 }
48 var actor any
49 if actorID != 0 {
50 actor = actorID
51 }
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)
56 if err != nil {
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
140 }
141 return res, rows.Err()
142}
143
144type AuditEntry struct {
145 ID int64 `json:"id"`
146 Actor string `json:"actor,omitempty"`
147 Action string `json:"action"`
148 Data string `json:"data"`
149 CreatedAt string `json:"created_at"`
150}
151
152// AuditFilter narrows AuditEntries. Actor is a username, or "-" for rows
153// with no actor (host commands, auth failures). ActionPrefix matches the
154// start of the action. Since is an ISO timestamp in the log's own format.
155type AuditFilter struct {
156 Actor string
157 ActionPrefix string
158 Since string
159 Limit int
160}
161
162func (s *Store) AuditEntries(f AuditFilter) ([]AuditEntry, error) {
163 q := `SELECT a.id, COALESCE(u.username, ''), a.action, a.data_json, a.created_at
164 FROM audit_log a LEFT JOIN users u ON u.id = a.actor_id WHERE 1 = 1`
165 var args []any
166 switch f.Actor {
167 case "":
168 case "-":
169 q += " AND a.actor_id IS NULL"
170 default:
171 q += " AND u.username = ?"
172 args = append(args, f.Actor)
173 }
174 if f.ActionPrefix != "" {
175 q += " AND substr(a.action, 1, length(?)) = ?"
176 args = append(args, f.ActionPrefix, f.ActionPrefix)
177 }
178 if f.Since != "" {
179 q += " AND a.created_at >= ?"
180 args = append(args, f.Since)
181 }
182 q += " ORDER BY a.id DESC LIMIT ?"
183 args = append(args, f.Limit)
184 rows, err := s.DB.Query(q, args...)
185 if err != nil {
186 return nil, err
187 }
188 defer rows.Close()
189 var out []AuditEntry
190 for rows.Next() {
191 var e AuditEntry
192 if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Data, &e.CreatedAt); err != nil {
193 return nil, err
194 }
195 out = append(out, e)
196 }
197 return out, rows.Err()
198}