package store import ( "context" "database/sql" "encoding/json" "strings" "time" "jarl/internal/model" ) // AddAudit stores an audit event. func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error { return addAudit(ctx, s.db, ev) } // AddAudit stores an audit event inside the transaction. func (t *Tx) AddAudit(ev *model.AuditEvent) error { return addAudit(t.ctx, t.tx, ev) } func addAudit(ctx context.Context, e execer, ev *model.AuditEvent) error { if ev.Time.IsZero() { ev.Time = time.Now() } var details any if len(ev.Details) > 0 { details = string(ev.Details) } res, err := e.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ts(ev.Time), ev.UserID, ev.Username, ev.Auth, ev.TokenID, ev.TokenName, ev.IP, ev.UserAgent, ev.Action, ev.ObjectType, ev.ObjectID, ev.ObjectName, ev.ChangesetID, ev.Outcome, ev.Message, details) if err != nil { return err } ev.ID, _ = res.LastInsertId() return nil } // AuditFilter selects audit events. type AuditFilter struct { Username string Action string // prefix match, e.g. "auth." or "changeset.apply" Outcome string ObjectType string ChangesetID int64 Since time.Time Until time.Time Search string // substring of object name or message Before int64 // IDs below this (paging) Limit int } // ListAudit returns audit events, newest first. func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) { var where []string var args []any if f.Username != "" { where = append(where, "username = ? COLLATE NOCASE") args = append(args, f.Username) } if f.Action != "" { where = append(where, "action LIKE ? ESCAPE '\\'") args = append(args, likeEscape(f.Action)+"%") } if f.Outcome != "" { where = append(where, "outcome = ?") args = append(args, f.Outcome) } if f.ObjectType != "" { where = append(where, "object_type = ?") args = append(args, f.ObjectType) } if f.ChangesetID > 0 { where = append(where, "changeset_id = ?") args = append(args, f.ChangesetID) } if !f.Since.IsZero() { where = append(where, "time >= ?") args = append(args, ts(f.Since)) } if !f.Until.IsZero() { where = append(where, "time < ?") args = append(args, ts(f.Until)) } if f.Search != "" { where = append(where, "(object_name LIKE ? ESCAPE '\\' OR message LIKE ? ESCAPE '\\')") p := "%" + likeEscape(f.Search) + "%" args = append(args, p, p) } if f.Before > 0 { where = append(where, "id < ?") args = append(args, f.Before) } q := `SELECT id, time, user_id, username, auth, token_id, token_name, ip, user_agent, action, object_type, object_id, object_name, changeset_id, outcome, message, details FROM audit` if len(where) > 0 { q += " WHERE " + strings.Join(where, " AND ") } limit := f.Limit if limit <= 0 || limit > 1000 { limit = 200 } q += " ORDER BY id DESC LIMIT ?" args = append(args, limit) rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var out []*model.AuditEvent for rows.Next() { ev := &model.AuditEvent{} var t string var details sql.NullString if err := rows.Scan(&ev.ID, &t, &ev.UserID, &ev.Username, &ev.Auth, &ev.TokenID, &ev.TokenName, &ev.IP, &ev.UserAgent, &ev.Action, &ev.ObjectType, &ev.ObjectID, &ev.ObjectName, &ev.ChangesetID, &ev.Outcome, &ev.Message, &details); err != nil { return nil, err } ev.Time = parseTS(t) if details.Valid && details.String != "" { ev.Details = json.RawMessage(details.String) } out = append(out, ev) } return out, rows.Err() } // PruneAudit deletes audit events older than before. func (s *Store) PruneAudit(ctx context.Context, before time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `DELETE FROM audit WHERE time < ?`, ts(before)) if err != nil { return 0, err } return res.RowsAffected() } func likeEscape(s string) string { r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) return r.Replace(s) } // DeployedFiles returns the files Jarl wrote last time it applied changes. func (s *Store) DeployedFiles(ctx context.Context) (map[string]*model.DeployedFile, error) { rows, err := s.db.QueryContext(ctx, `SELECT path, sha256, size, kind, written_at, changeset_id FROM deployed_files`) if err != nil { return nil, err } defer rows.Close() out := map[string]*model.DeployedFile{} for rows.Next() { f := &model.DeployedFile{} var wa string if err := rows.Scan(&f.Path, &f.SHA256, &f.Size, &f.Kind, &wa, &f.ChangesetID); err != nil { return nil, err } f.WrittenAt = parseTS(wa) out[f.Path] = f } return out, rows.Err() } // SetDeployedFiles replaces the record of deployed files. func (t *Tx) SetDeployedFiles(files []*model.DeployedFile) error { if _, err := t.tx.ExecContext(t.ctx, `DELETE FROM deployed_files`); err != nil { return err } for _, f := range files { if _, err := t.tx.ExecContext(t.ctx, `INSERT INTO deployed_files (path, sha256, size, kind, written_at, changeset_id) VALUES (?,?,?,?,?,?)`, f.Path, f.SHA256, f.Size, f.Kind, ts(f.WrittenAt), f.ChangesetID); err != nil { return err } } return nil }