Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a1046e-fcad-7ea3-bf36-361fc8d679e8
233 lines
6.6 KiB
Go
233 lines
6.6 KiB
Go
package emission
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const HeartbeatType = "audit-core.heartbeat"
|
|
|
|
// RareClasses is the source-owned inventory in cadence.yaml.
|
|
func RareClasses() []string {
|
|
return []string{ClassPrefix + "deny", ClassPrefix + "redact", ClassPrefix + "not_applicable", ClassPrefix + "audit_only"}
|
|
}
|
|
|
|
type heartbeatData struct {
|
|
Class string `json:"class"`
|
|
Assertion string `json:"assertion"`
|
|
Committed uint64 `json:"committed"`
|
|
ReleasedUncommitted uint64 `json:"released_uncommitted"`
|
|
}
|
|
|
|
func (o *Outbox) noteHeartbeat(event Event) {
|
|
if event.Type != HeartbeatType {
|
|
return
|
|
}
|
|
b, err := json.Marshal(event.Data)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var data heartbeatData
|
|
if json.Unmarshal(b, &data) != nil {
|
|
return
|
|
}
|
|
at, err := time.Parse(time.RFC3339Nano, event.OccurredAt)
|
|
if err == nil {
|
|
o.heartbeats[data.Class] = at
|
|
}
|
|
}
|
|
|
|
// AppendDueHeartbeats commits one heartbeat per rare class at most every 24h.
|
|
// The timestamp is rebuilt from the log on restart, never from a process timer.
|
|
func (o *Outbox) AppendDueHeartbeats() error {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
now := o.clock().UTC()
|
|
var events []Event
|
|
for _, class := range RareClasses() {
|
|
if at, ok := o.heartbeats[class]; ok && now.Sub(at) < 24*time.Hour {
|
|
continue
|
|
}
|
|
id, err := eventID()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assertion := "nothing-to-report"
|
|
if o.uncommitted[class] > 0 {
|
|
assertion = "released-uncommitted-finding"
|
|
}
|
|
events = append(events, Event{ID: id, Type: HeartbeatType, Source: o.source, Subject: o.source,
|
|
Tenant: PlatformTenant, CorrelationID: id, OccurredAt: now.Format(time.RFC3339Nano),
|
|
Data: heartbeatData{Class: class, Assertion: assertion, Committed: o.committed[class], ReleasedUncommitted: o.uncommitted[class]}})
|
|
}
|
|
if len(events) == 0 {
|
|
return nil
|
|
}
|
|
return o.appendEvents(now.Format(time.RFC3339Nano), events)
|
|
}
|
|
|
|
type drainCursor struct {
|
|
Seq uint64 `json:"seq"`
|
|
Offset int64 `json:"offset"`
|
|
}
|
|
|
|
func (o *Outbox) loadCursor() error {
|
|
b, err := os.ReadFile(filepath.Join(filepath.Dir(o.path), "drain.json"))
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read drain cursor: %w", err)
|
|
}
|
|
if err := json.Unmarshal(b, &o.cursor); err != nil {
|
|
return fmt.Errorf("decode drain cursor: %w", err)
|
|
}
|
|
if o.cursor.Offset < 0 || (o.cursor.Seq == 0) != (o.cursor.Offset == 0) {
|
|
return errors.New("invalid drain cursor")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nextEntry reads only the committed prefix; mu protects the size snapshot.
|
|
func (o *Outbox) nextEntry() (entry, int64, error) {
|
|
f, err := os.Open(o.path)
|
|
if err != nil {
|
|
return entry{}, 0, err
|
|
}
|
|
defer f.Close()
|
|
r := bufio.NewReader(io.NewSectionReader(f, o.cursor.Offset, o.size-o.cursor.Offset))
|
|
line, err := r.ReadBytes('\n')
|
|
if err != nil {
|
|
return entry{}, 0, err
|
|
}
|
|
var e entry
|
|
if err := json.Unmarshal(line, &e); err != nil {
|
|
return entry{}, 0, err
|
|
}
|
|
if e.Seq != o.cursor.Seq+1 || e.Event.Source != o.source {
|
|
return entry{}, 0, errors.New("inconsistent next outbox entry")
|
|
}
|
|
return e, o.cursor.Offset + int64(len(line)), nil
|
|
}
|
|
|
|
func (o *Outbox) saveCursor(cursor drainCursor) error {
|
|
dir := filepath.Dir(o.path)
|
|
f, err := os.CreateTemp(dir, ".drain-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
b, _ := json.Marshal(cursor)
|
|
if _, err = f.Write(b); err == nil {
|
|
err = f.Sync()
|
|
}
|
|
err = errors.Join(err, f.Close())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = os.Rename(f.Name(), filepath.Join(dir, "drain.json")); err != nil {
|
|
return err
|
|
}
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer d.Close()
|
|
return d.Sync()
|
|
}
|
|
|
|
// DeliveryConfig is supplied only after sender admission. Tokens are read from
|
|
// a mounted file for each event to support rotation; they never enter logs.
|
|
type DeliveryConfig struct {
|
|
URL string
|
|
TokenFile string
|
|
}
|
|
|
|
func (c DeliveryConfig) Validate() error {
|
|
u, err := url.Parse(c.URL)
|
|
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/v1/events" {
|
|
return errors.New("audit URL must be an explicit http(s) /v1/events endpoint without credentials, query or fragment")
|
|
}
|
|
if c.TokenFile == "" {
|
|
return errors.New("audit delivery requires a token file")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Drain sends a bounded batch sequentially. Only 200/202 advance the durable
|
|
// cursor. Failed or ambiguous requests replay the same event/idempotency key.
|
|
// No receiver error payload or token is included in an error.
|
|
func (o *Outbox) Drain(ctx context.Context, config DeliveryConfig, limit int) (int, error) {
|
|
if err := config.Validate(); err != nil {
|
|
return 0, err
|
|
}
|
|
if limit < 1 {
|
|
return 0, errors.New("drain limit must be positive")
|
|
}
|
|
o.drainMu.Lock()
|
|
defer o.drainMu.Unlock()
|
|
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
|
delivered := 0
|
|
for delivered < limit {
|
|
o.mu.Lock()
|
|
if o.cursor.Seq == o.seq {
|
|
o.mu.Unlock()
|
|
return delivered, nil
|
|
}
|
|
e, offset, err := o.nextEntry()
|
|
o.mu.Unlock()
|
|
if err != nil {
|
|
return delivered, fmt.Errorf("read pending event: %w", err)
|
|
}
|
|
token, err := os.ReadFile(config.TokenFile)
|
|
if err != nil {
|
|
return delivered, errors.New("audit token file unavailable")
|
|
}
|
|
value := strings.TrimSpace(string(token))
|
|
if value == "" || strings.ContainsAny(value, "\r\n") {
|
|
return delivered, errors.New("audit token file is empty or invalid")
|
|
}
|
|
body, err := json.Marshal(e.Event)
|
|
if err != nil {
|
|
return delivered, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.URL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return delivered, errors.New("construct audit request failed")
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+value)
|
|
req.Header.Set("Idempotency-Key", e.Event.ID)
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return delivered, errors.New("audit delivery failed; event retained")
|
|
}
|
|
response.Body.Close()
|
|
if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusAccepted {
|
|
return delivered, fmt.Errorf("audit delivery HTTP %d; event retained", response.StatusCode)
|
|
}
|
|
o.mu.Lock()
|
|
cursor := drainCursor{Seq: e.Seq, Offset: offset}
|
|
err = o.saveCursor(cursor)
|
|
if err == nil {
|
|
o.cursor = cursor
|
|
}
|
|
o.mu.Unlock()
|
|
if err != nil {
|
|
return delivered, fmt.Errorf("persist drain cursor: %w", err)
|
|
}
|
|
delivered++
|
|
}
|
|
return delivered, nil
|
|
}
|