Files
DDI/server/internal/store/audit.go
T
beug.k@beugsiud.deandClaude Opus 5.5 28dc177310 feat(store): migration 00004 for push deployments, target revisions and revision sources
Agents get a target revision (manual mode), revisions store their
generator input (jsonb) for rollbacks, deployments gain the outcomes
pending and superseded, a nullable end and the requesting user. The
duplicate protection now covers agent reports only. Transactions can
register hooks that run after the commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:45 +02:00

175 lines
5.2 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package store
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// AuditEntry is a row of the audit log.
type AuditEntry struct {
ID int64 `json:"id"`
At time.Time `json:"at"`
ActorID *string `json:"actor_id,omitempty"`
ActorName string `json:"actor_name"`
Action string `json:"action"`
ObjectType string `json:"object_type"`
ObjectID *string `json:"object_id,omitempty"`
ObjectName string `json:"object_name"`
Before json.RawMessage `json:"before,omitempty"`
After json.RawMessage `json:"after,omitempty"`
RequestID string `json:"request_id"`
IP *netip.Addr `json:"ip,omitempty"`
}
// InsertAudit appends an entry. It must run in the same transaction as the
// change it records, so that the log is complete by construction.
func (t *Tx) InsertAudit(ctx context.Context, e *AuditEntry) error {
row := t.q.QueryRow(ctx, `
INSERT INTO audit_log (actor_id, actor_name, action, object_type, object_id, object_name, before, after, request_id, ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, at`,
e.ActorID, e.ActorName, e.Action, e.ObjectType, e.ObjectID, e.ObjectName,
nullJSON(e.Before), nullJSON(e.After), e.RequestID, e.IP)
return mapErr(row.Scan(&e.ID, &e.At))
}
func nullJSON(m json.RawMessage) any {
if len(m) == 0 {
return nil
}
return []byte(m)
}
// AuditFilter selects audit entries. Zero values mean "no restriction".
type AuditFilter struct {
From time.Time
To time.Time
ActorID string
ObjectType string
ObjectID string
Action string
Query string // substring of actor, action, object name
Limit int
BeforeID int64 // keyset pagination: entries with id < BeforeID
}
// ListAudit returns entries newest first.
func (t *Tx) ListAudit(ctx context.Context, f AuditFilter) ([]*AuditEntry, error) {
var where []string
var args []any
add := func(cond string, v any) {
args = append(args, v)
where = append(where, fmt.Sprintf(cond, len(args)))
}
if !f.From.IsZero() {
add("at >= $%d", f.From)
}
if !f.To.IsZero() {
add("at < $%d", f.To)
}
if f.ActorID != "" {
add("actor_id = $%d", f.ActorID)
}
if f.ObjectType != "" {
add("object_type = $%d", f.ObjectType)
}
if f.ObjectID != "" {
add("object_id = $%d", f.ObjectID)
}
if f.Action != "" {
add("action = $%d", f.Action)
}
if f.Query != "" {
args = append(args, "%"+escapeLike(f.Query)+"%")
n := len(args)
where = append(where, fmt.Sprintf("(actor_name ILIKE $%[1]d OR action ILIKE $%[1]d OR object_name ILIKE $%[1]d)", n))
}
if f.BeforeID > 0 {
add("id < $%d", f.BeforeID)
}
limit := f.Limit
if limit <= 0 || limit > 10000 {
limit = 100
}
sql := `SELECT id, at, actor_id, actor_name, action, object_type, object_id, object_name, before, after, request_id, ip FROM audit_log`
if len(where) > 0 {
sql += " WHERE " + strings.Join(where, " AND ")
}
sql += fmt.Sprintf(" ORDER BY id DESC LIMIT %d", limit)
rows, err := t.q.Query(ctx, sql, args...)
if err != nil {
return nil, mapErr(err)
}
return scanAuditRows(rows)
}
// ChangeFilter selects the audit entries that affect the generated
// configuration (ConfigChangesSince).
type ChangeFilter struct {
ObjectTypes []string
Actions []string
// AfterRevision is the revision whose creation (audit action
// revision.create or revision.rollback) starts the range; 0 = from the
// beginning.
AfterRevision int64
// Since starts the range if the revision has no audit entry.
Since time.Time
Limit int
}
// ConfigChangesSince returns matching entries newest first. The range is
// bounded by the ID of the revision's own audit entry, so the clocks of
// server and database do not matter.
func (t *Tx) ConfigChangesSince(ctx context.Context, f ChangeFilter) ([]*AuditEntry, error) {
limit := f.Limit
if limit <= 0 || limit > 10000 {
limit = 100
}
rows, err := t.q.Query(ctx, `
SELECT id, at, actor_id, actor_name, action, object_type, object_id, object_name, before, after, request_id, ip
FROM audit_log
WHERE (object_type = ANY($1) OR action = ANY($2))
AND id > coalesce(
(SELECT max(id) FROM audit_log WHERE object_type = 'revision' AND object_id = $3
AND action IN ('revision.create', 'revision.rollback')),
(SELECT max(id) FROM audit_log WHERE at < $4),
0)
ORDER BY id DESC LIMIT `+strconv.Itoa(limit),
f.ObjectTypes, f.Actions, strconv.FormatInt(f.AfterRevision, 10), f.Since)
if err != nil {
return nil, mapErr(err)
}
return scanAuditRows(rows)
}
func scanAuditRows(rows pgx.Rows) ([]*AuditEntry, error) {
defer rows.Close()
var out []*AuditEntry
for rows.Next() {
var e AuditEntry
var before, after []byte
if err := rows.Scan(&e.ID, &e.At, &e.ActorID, &e.ActorName, &e.Action, &e.ObjectType, &e.ObjectID,
&e.ObjectName, &before, &after, &e.RequestID, &e.IP); err != nil {
return nil, mapErr(err)
}
e.Before, e.After = before, after
out = append(out, &e)
}
return out, mapErr(rows.Err())
}
// escapeLike escapes LIKE wildcards in user input.
func escapeLike(s string) string {
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
}