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>
175 lines
5.2 KiB
Go
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)
|
|
}
|