access-engine/internal/emission/delivery.go
tegwick 0a05383195
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Failing after 1m1s
Bound durable outbox storage and enforce process writer exclusion
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 18:43:52 +02:00

251 lines
7 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 {
file, err := openOwnedFile(filepath.Join(filepath.Dir(o.path), "drain.json"), os.O_RDONLY, 0)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read drain cursor: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
return errors.New("drain cursor must be a regular file")
}
b, err := io.ReadAll(io.LimitReader(file, 4097))
if err != nil {
return err
}
if len(b) > 4096 {
return errors.New("drain cursor exceeds 4096 bytes")
}
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 := openOwnedFile(o.path, os.O_RDONLY, 0)
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 := boundedLine(r)
if err != nil {
return entry{}, 0, err
}
var e entry
decoder := json.NewDecoder(bytes.NewReader(line))
decoder.UseNumber()
if err := decoder.Decode(&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.closed {
o.mu.Unlock()
return delivered, os.ErrClosed
}
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
}