access-engine/internal/emission/outbox.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

427 lines
12 KiB
Go

// Package emission delivers the decision-record emission guarantee declared in
// cadence.yaml (FLEX-WP-0031). A decision is released only after its event is
// durably committed to a local outbox (FLEX-DEC-2026-018); draining to
// audit-core is asynchronous.
package emission
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
"github.com/netkingdom/access-engine/pkg/api"
)
// ClassPrefix prefixes every decision event class; the suffix is the effect.
const ClassPrefix = "flex-auth.decision."
// PlatformTenant is sent when a decision names no tenant, because audit-core
// requires one on every event.
const PlatformTenant = "tenant:platform"
const outboxFile = "outbox.jsonl"
const MaxOutboxBytes int64 = 64 * 1024 * 1024
const MaxEntryBytes = 2 * 1024 * 1024
const MaxBatchBytes = MaxEntryBytes
var ErrCapacity = errors.New("outbox capacity exceeded; committed evidence retained")
// Class returns the event class for a decision effect.
func Class(effect api.DecisionEffect) string {
return ClassPrefix + string(effect)
}
// Event is one audit-core.event envelope: exactly the fields audit-core's
// normalize() requires. Data carries the decision record itself.
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Source string `json:"source"`
Subject string `json:"subject"`
Tenant string `json:"tenant"`
CorrelationID string `json:"correlation_id"`
OccurredAt string `json:"occurred_at"`
Data any `json:"data"`
}
// entry is one committed outbox line.
type entry struct {
Seq uint64 `json:"seq"`
CommittedAt string `json:"committed_at"`
Event Event `json:"event"`
}
// Status is the local half of reconciliation: what this outbox committed, per
// class, and what it released without committing.
type Status struct {
Source string `json:"source"`
MaxBytes int64 `json:"max_bytes"`
CommittedBytes int64 `json:"committed_bytes"`
RemainingBytes int64 `json:"remaining_bytes"`
WriterFenced bool `json:"writer_fenced"`
CapacityExhausted bool `json:"capacity_exhausted"`
Committed map[string]uint64 `json:"committed"`
ReleasedUncommitted map[string]uint64 `json:"released_uncommitted"`
// ReleasedUncommitted is held in memory: a restart during an outbox outage
// loses it (FLEX-DEC-2026-018, consequences).
Means string `json:"means"`
Pending uint64 `json:"pending"`
OldestPendingAt string `json:"oldest_pending_at,omitempty"`
LagExceeded bool `json:"lag_exceeded"`
}
// Outbox is a durable, append-only, fsync-per-commit event log.
type Outbox struct {
source string
path string
clock func() time.Time
mu sync.Mutex
file *os.File
writerLock *os.File
maxBytes int64
closed bool
writeFault bool
seq uint64
size int64 // bytes committed; a failed commit is cut back to it
committed map[string]uint64
uncommitted map[string]uint64
drainMu sync.Mutex
cursor drainCursor
heartbeats map[string]time.Time
}
// Open opens or creates the outbox in dir and rebuilds committed counts from it.
func Open(dir, source string) (*Outbox, error) { return OpenBounded(dir, source, MaxOutboxBytes) }
// OpenBounded acquires OS writer exclusion before inspecting/recovering evidence.
// The selected limit cannot exceed the reviewed 64MiB source ceiling.
func OpenBounded(dir, source string, maxBytes int64) (*Outbox, error) {
if maxBytes < 1 || maxBytes > MaxOutboxBytes {
return nil, errors.New("outbox byte limit must be between 1 and 67108864")
}
if source == "" {
return nil, errors.New("emission source is required")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create outbox directory: %w", err)
}
writerLock, err := acquireWriterLock(filepath.Join(dir, "writer.lock"))
if err != nil {
return nil, err
}
success := false
defer func() {
if !success {
writerLock.Close()
}
}()
o := &Outbox{
source: source,
writerLock: writerLock,
maxBytes: maxBytes,
path: filepath.Join(dir, outboxFile),
clock: time.Now,
committed: map[string]uint64{},
uncommitted: map[string]uint64{},
heartbeats: map[string]time.Time{},
}
if err := o.loadCursor(); err != nil {
return nil, err
}
if err := o.load(); err != nil {
return nil, err
}
if o.cursor.Seq > o.seq || o.cursor.Offset > o.size {
return nil, errors.New("drain cursor is ahead of committed outbox")
}
file, err := openOwnedFile(o.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640)
if err != nil {
return nil, fmt.Errorf("open outbox: %w", err)
}
o.file = file
if err := syncOutboxDirectory(dir); err != nil {
file.Close()
return nil, err
}
success = true
return o, nil
}
func (o *Outbox) load() error {
file, err := openOwnedFile(o.path, os.O_RDONLY, 0)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read outbox: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("outbox must be a regular file")
}
if info.Size() > o.maxBytes {
return ErrCapacity
}
reader := bufio.NewReaderSize(file, 64*1024)
var good int64
for line := 1; ; line++ {
raw, err := boundedLine(reader)
if err == io.EOF {
// Only an unterminated final write is uncommitted. Never discard a
// newline-terminated corrupt or oversized record.
o.size = good
if len(raw) > 0 {
if err := os.Truncate(o.path, good); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
}
break
}
if err != nil {
return fmt.Errorf("outbox line %d: %w", line, err)
}
var e entry
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&e); err != nil {
return fmt.Errorf("outbox line %d is corrupt: %w", line, err)
}
if _, err := decoder.Token(); err != io.EOF {
return errors.New("trailing outbox entry data")
}
if e.Seq != o.seq+1 || e.Event.Source != o.source {
return fmt.Errorf("outbox line %d has inconsistent sequence or source", line)
}
o.seq = e.Seq
o.committed[e.Event.Type]++
good += int64(len(raw))
if e.Seq == o.cursor.Seq && good != o.cursor.Offset {
return errors.New("drain cursor does not match outbox")
}
o.noteHeartbeat(e.Event)
}
if o.cursor.Seq > o.seq {
return errors.New("drain cursor is ahead of outbox")
}
return nil
}
// boundedLine bounds startup and delivery allocations independently of log size.
// Its 2MiB limit includes the record newline.
func boundedLine(reader *bufio.Reader) ([]byte, error) {
var line []byte
for {
part, err := reader.ReadSlice('\n')
if len(line)+len(part) > MaxEntryBytes {
return nil, ErrCapacity
}
line = append(line, part...)
if err == bufio.ErrBufferFull {
continue
}
return line, err
}
}
// SetClock overrides the commit clock. Tests use it.
func (o *Outbox) SetClock(clock func() time.Time) {
o.mu.Lock()
defer o.mu.Unlock()
o.clock = clock
}
// Close closes the outbox file.
func (o *Outbox) Close() error {
// Drain holds drainMu through HTTP and cursor commit. Do not release writer
// exclusion while an old delivery can still mutate the cursor.
o.drainMu.Lock()
defer o.drainMu.Unlock()
o.mu.Lock()
defer o.mu.Unlock()
if o.closed {
return nil
}
o.closed = true
return errors.Join(o.file.Close(), o.writerLock.Close())
}
// Append commits one decision; it satisfies decision.DecisionRecorder.
func (o *Outbox) Append(decision api.DecisionEnvelope) error {
return o.AppendBatch([]api.DecisionEnvelope{decision})
}
// AppendBatch commits decisions with one sync, so a batch check pays the
// durable-write cost once rather than per resource.
func (o *Outbox) AppendBatch(decisions []api.DecisionEnvelope) error {
o.mu.Lock()
defer o.mu.Unlock()
now := o.clock().UTC().Format(time.RFC3339Nano)
var events []Event
for _, decision := range decisions {
id, err := eventID()
if err != nil {
return err
}
events = append(events, o.event(id, now, decision))
}
return o.appendEvents(now, events)
}
// appendEvents requires mu; heartbeats use the same durable commit as decisions.
func (o *Outbox) appendEvents(now string, events []Event) error {
if o.closed {
return os.ErrClosed
}
if o.writeFault {
return errors.New("outbox rollback unconfirmed; writer fenced")
}
var buf []byte
seq := o.seq
for _, event := range events {
seq++
line, err := json.Marshal(entry{Seq: seq, CommittedAt: now, Event: event})
if err != nil {
return fmt.Errorf("marshal outbox entry: %w", err)
}
if len(line)+1 > MaxEntryBytes || len(buf)+len(line)+1 > MaxBatchBytes || int64(len(buf))+int64(len(line))+1 > o.maxBytes-o.size {
return ErrCapacity
}
buf = append(append(buf, line...), '\n')
}
if _, err := o.file.Write(buf); err != nil {
return o.rollback(fmt.Errorf("write outbox: %w", err))
}
if err := o.file.Sync(); err != nil {
return o.rollback(fmt.Errorf("sync outbox: %w", err))
}
o.seq = seq
o.size += int64(len(buf))
for _, event := range events {
o.committed[event.Type]++
o.noteHeartbeat(event)
}
return nil
}
// rollback cuts a failed commit back to the last committed size, so a partial
// line never sits in front of the next commit. The commit error is returned
// either way; a failed cut is reported alongside it.
func (o *Outbox) rollback(cause error) error {
err := o.file.Truncate(o.size)
if err == nil {
err = o.file.Sync()
}
if err != nil {
o.writeFault = true
return errors.Join(cause, fmt.Errorf("outbox rollback unconfirmed; writer fenced: %w", err))
}
return cause
}
func syncOutboxDirectory(dir string) error {
file, err := os.Open(dir)
if err != nil {
return err
}
defer file.Close()
return file.Sync()
}
// NoteReleasedUncommitted counts a restriction released after its commit
// failed. Every non-zero count is a finding.
func (o *Outbox) NoteReleasedUncommitted(effect api.DecisionEffect) {
o.mu.Lock()
defer o.mu.Unlock()
o.uncommitted[Class(effect)]++
}
// Status reports committed and released-uncommitted counts per class.
func (o *Outbox) Status() Status {
o.mu.Lock()
defer o.mu.Unlock()
status := Status{
Source: o.source,
MaxBytes: o.maxBytes, CommittedBytes: o.size, RemainingBytes: o.maxBytes - o.size, CapacityExhausted: o.size == o.maxBytes, WriterFenced: o.writeFault,
Committed: map[string]uint64{},
ReleasedUncommitted: map[string]uint64{},
Means: "committed counts this outbox holds since it was created; " +
"released_uncommitted is decisions released without a record " +
"since this process started, and any non-zero value is a finding",
}
status.Pending = o.seq - o.cursor.Seq
if status.Pending > 0 {
if e, _, err := o.nextEntry(); err == nil {
status.OldestPendingAt = e.CommittedAt
at, _ := time.Parse(time.RFC3339Nano, e.CommittedAt)
status.LagExceeded = status.Pending > 100 || o.clock().Sub(at) > time.Hour
} else {
status.LagExceeded = true
}
}
for _, effect := range api.DecisionEffects() {
class := Class(effect)
status.Committed[class] = o.committed[class]
status.ReleasedUncommitted[class] = o.uncommitted[class]
}
return status
}
func (o *Outbox) event(id, at string, decision api.DecisionEnvelope) Event {
tenant := decision.Resource.Tenant
if decision.Binding != nil && decision.Binding.Tenant != "" {
tenant = decision.Binding.Tenant
}
if tenant == "" {
tenant = PlatformTenant
}
return Event{
ID: id,
Type: Class(decision.Effect),
Source: o.source,
Subject: resourceSubject(decision.Resource),
Tenant: tenant,
CorrelationID: decision.ID,
OccurredAt: at,
Data: decision,
}
}
func resourceSubject(resource api.ResourceRef) string {
subject := resource.ID
if resource.Type != "" {
subject = resource.Type + "/" + subject
}
if resource.System != "" {
subject = resource.System + "/" + subject
}
return subject
}
// eventID is random, not derived from the decision: decision ids are content
// digests, so two identical checks share one, and audit-core's idempotency key
// would merge two real decisions into one.
func eventID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("event id: %w", err)
}
return "flex-auth-evt:" + hex.EncodeToString(b[:]), nil
}