internal/store/audit.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/store/audit.go history · blame · raw

198 lines · 6656 bytes

  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}