Files
relata/internal/audit/audit.go
T
2026-08-11 10:40:10 +01:00

93 lines
2.9 KiB
Go

// Package audit records consequential actions to an append-only log.
//
// The recorder never writes credentials, session tokens or document bodies. It
// stores who did what to which record, when, and a small map of short
// descriptive values.
package audit
import (
"context"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/observability"
)
// Recorder writes audit events.
type Recorder struct {
repo domain.AuditRepository
}
// NewRecorder builds an audit recorder.
func NewRecorder(repo domain.AuditRepository) *Recorder { return &Recorder{repo: repo} }
// maxDetailValue bounds each detail value, since some come from user input.
const maxDetailValue = 300
// Record appends an audit event.
//
// Failing to write an audit record must not fail the action that prompted it —
// refusing to serve a request because the audit log is unavailable would turn a
// logging outage into an outage of the whole application. The failure is logged
// at error level instead.
func (r *Recorder) Record(ctx context.Context, p *domain.Principal, action, targetKind, targetID, outcome string, detail map[string]string) {
if r == nil || r.repo == nil {
return
}
ev := &domain.AuditEvent{
ID: ids.New(),
Action: action,
TargetKind: targetKind,
TargetID: targetID,
Outcome: outcome,
RequestID: observability.RequestID(ctx),
}
if p != nil {
ev.WorkspaceID = p.WorkspaceID
ev.ActorID = p.UserID
ev.ActorName = p.Username
}
if len(detail) > 0 {
ev.Detail = make(map[string]string, len(detail))
for k, v := range detail {
if len(v) > maxDetailValue {
v = v[:maxDetailValue] + "…"
}
ev.Detail[k] = v
}
}
// Use a context detached from the request so an audit write still lands
// when the client has already disconnected.
if err := r.repo.Record(context.WithoutCancel(ctx), ev); err != nil {
observability.LoggerFrom(ctx).Error("could not write audit event",
"action", action, "target_id", targetID, "error", err.Error())
}
}
// RecordAnonymous writes an event with no authenticated actor, used for failed
// sign-in attempts where there is no principal yet.
func (r *Recorder) RecordAnonymous(ctx context.Context, workspaceID, action, outcome string, detail map[string]string) {
if r == nil || r.repo == nil {
return
}
ev := &domain.AuditEvent{
ID: ids.New(),
WorkspaceID: workspaceID,
Action: action,
Outcome: outcome,
RequestID: observability.RequestID(ctx),
Detail: detail,
}
if err := r.repo.Record(context.WithoutCancel(ctx), ev); err != nil {
observability.LoggerFrom(ctx).Error("could not write audit event",
"action", action, "error", err.Error())
}
}
// List returns a page of audit events for a workspace.
func (r *Recorder) List(ctx context.Context, workspaceID string, p domain.Page) ([]*domain.AuditEvent, int64, error) {
return r.repo.List(ctx, workspaceID, p)
}