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 }