Files
msa-agent/internal/runtime/audit.go
T

90 lines
2.8 KiB
Go

package runtime
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Entry is one line of the audit log.
//
// Metadata only, and that is the whole design: the request and response bodies are the actual
// credentials and SMS contents, and copying them into a second file on the same box adds a place to
// lose them without adding any investigative value. What is kept is enough to answer "who asked for
// what, when, and was it allowed" from the relay request id.
type Entry struct {
At time.Time `json:"ts"`
Node string `json:"node,omitempty"`
Req string `json:"req,omitempty"`
App string `json:"app,omitempty"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Status int `json:"status"`
Outcome string `json:"outcome"`
}
// Audit is an append-only JSONL writer. One line per request, flushed as it is written: an audit log
// that loses its tail on SIGKILL cannot explain the incident that killed the process.
type Audit struct {
mu sync.Mutex
sink *os.File
enc *json.Encoder
}
// OpenAudit appends to path, creating it 0600 and its parent directories if needed.
//
// The file is opened append-only and never rotated in-process. Rotation is logrotate's job: an
// agent that renames its own log is an agent that can lose an entry during the rename.
func OpenAudit(path string) (*Audit, error) {
if path == "" {
return nil, errors.New("runtime: audit path is empty")
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
sink, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
return &Audit{sink: sink, enc: json.NewEncoder(sink)}, nil
}
// NullAudit returns a writer that keeps nothing. Used by tests and by `msa-agent --audit=-`, because
// the executor is simpler when it can assume a non-nil audit target.
func NullAudit() *Audit { return &Audit{} }
// Write appends one entry. A write failure is returned to the caller rather than panicking: the
// audit log is evidence, not a control, and a full disk must not become a remote denial of service.
func (a *Audit) Write(entry Entry) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
if entry.At.IsZero() {
entry.At = time.Now().UTC()
}
entry.At = entry.At.UTC()
if err := a.enc.Encode(entry); err != nil {
return fmt.Errorf("runtime: audit write: %w", err)
}
return a.sink.Sync()
}
// Close releases the file. Subsequent writes are silently dropped, which is the right behaviour for
// a shutdown path: failing the request that happens to race with a clean exit buys nothing.
func (a *Audit) Close() error {
a.mu.Lock()
defer a.mu.Unlock()
if a.sink == nil {
return nil
}
err := a.sink.Close()
a.sink = nil
return err
}