Add local decision log
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 05:51:37 +02:00
parent 4342f98d83
commit 2b103ea70b
6 changed files with 211 additions and 7 deletions

View File

@@ -1,6 +1,3 @@
// Package audit persists compact decision envelopes. Denies, redactions,
// exports, and emergency actions are always recorded; allows may be
// sampled.
//
// Implementation lands in FLEX-WP-0002 P2.6.
// exports, and emergency actions are always recorded; allows may be sampled.
package audit

94
internal/audit/log.go Normal file
View File

@@ -0,0 +1,94 @@
package audit
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/netkingdom/flex-auth/pkg/api"
)
// JSONLDecisionLog persists compact decision envelopes for local development.
type JSONLDecisionLog struct {
path string
mu sync.Mutex
}
// NewJSONLDecisionLog returns a JSONL-backed decision log.
func NewJSONLDecisionLog(path string) *JSONLDecisionLog {
return &JSONLDecisionLog{path: path}
}
// Append writes one decision envelope as one JSON line.
func (l *JSONLDecisionLog) Append(decision api.DecisionEnvelope) error {
l.mu.Lock()
defer l.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
return fmt.Errorf("create decision log directory: %w", err)
}
file, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open decision log: %w", err)
}
defer file.Close()
data, err := json.Marshal(decision)
if err != nil {
return fmt.Errorf("marshal decision envelope: %w", err)
}
if _, err := file.Write(append(data, '\n')); err != nil {
return fmt.Errorf("write decision log: %w", err)
}
return nil
}
// ReadAll returns every decision envelope from the log in file order.
func (l *JSONLDecisionLog) ReadAll() ([]api.DecisionEnvelope, error) {
l.mu.Lock()
defer l.mu.Unlock()
file, err := os.Open(l.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("open decision log: %w", err)
}
defer file.Close()
var decisions []api.DecisionEnvelope
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if scanner.Text() == "" {
continue
}
var decision api.DecisionEnvelope
if err := json.Unmarshal(scanner.Bytes(), &decision); err != nil {
return nil, fmt.Errorf("unmarshal decision log line %d: %w", len(decisions)+1, err)
}
decisions = append(decisions, decision)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read decision log: %w", err)
}
return decisions, nil
}
// Find returns one decision envelope by id.
func (l *JSONLDecisionLog) Find(id string) (api.DecisionEnvelope, bool, error) {
decisions, err := l.ReadAll()
if err != nil {
return api.DecisionEnvelope{}, false, err
}
for _, decision := range decisions {
if decision.ID == id {
return decision, true, nil
}
}
return api.DecisionEnvelope{}, false, nil
}

View File

@@ -0,0 +1,59 @@
package audit_test
import (
"path/filepath"
"testing"
"github.com/netkingdom/flex-auth/internal/audit"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestJSONLDecisionLogRoundTripsDecisions(t *testing.T) {
log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "decisions.jsonl"))
first := api.DecisionEnvelope{
ID: "decision:allow",
Effect: api.DecisionEffectAllow,
Reason: "reader_relation",
Subject: api.SubjectRef{
ID: "user:alice",
},
Resource: api.ResourceRef{
ID: "document:internal-note",
},
Provenance: api.DecisionProvenance{
Evaluator: "flex-auth/local",
Mode: "standalone",
},
}
second := first
second.ID = "decision:deny"
second.Effect = api.DecisionEffectDeny
second.Reason = "no_matching_rule"
if err := log.Append(first); err != nil {
t.Fatalf("Append first: %v", err)
}
if err := log.Append(second); err != nil {
t.Fatalf("Append second: %v", err)
}
got, err := log.ReadAll()
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d; want 2", len(got))
}
if got[0].ID != first.ID || got[1].ID != second.ID {
t.Fatalf("ids = %q/%q; want %q/%q", got[0].ID, got[1].ID, first.ID, second.ID)
}
found, ok, err := log.Find("decision:deny")
if err != nil {
t.Fatalf("Find: %v", err)
}
if !ok || found.Effect != api.DecisionEffectDeny {
t.Fatalf("Find decision:deny = %+v, %v; want deny", found, ok)
}
}