diff --git a/internal/audit/doc.go b/internal/audit/doc.go index 876f79a..e1b1618 100644 --- a/internal/audit/doc.go +++ b/internal/audit/doc.go @@ -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 diff --git a/internal/audit/log.go b/internal/audit/log.go new file mode 100644 index 0000000..3c325c5 --- /dev/null +++ b/internal/audit/log.go @@ -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 +} diff --git a/internal/audit/log_test.go b/internal/audit/log_test.go new file mode 100644 index 0000000..086a03f --- /dev/null +++ b/internal/audit/log_test.go @@ -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) + } +} diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 865e2f3..6698b6f 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -23,6 +23,12 @@ type Engine struct { policy *policy.Package mu sync.RWMutex history map[string]api.DecisionEnvelope + log DecisionRecorder +} + +// DecisionRecorder persists decision envelopes. +type DecisionRecorder interface { + Append(api.DecisionEnvelope) error } // ListAllowedRequest describes a deterministic list_allowed call. @@ -69,6 +75,13 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e }, nil } +// SetDecisionLog attaches a local decision recorder to the engine. +func (e *Engine) SetDecisionLog(log DecisionRecorder) { + e.mu.Lock() + defer e.mu.Unlock() + e.log = log +} + // Check evaluates one subject/action/resource request. func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) { normalized, facts := e.normalizeRequest(request) @@ -79,7 +92,9 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis } decision := e.envelope(normalized, expectation, facts) - e.recordDecision(decision) + if err := e.recordDecision(decision); err != nil { + return api.DecisionEnvelope{}, err + } return decision, nil } @@ -286,10 +301,14 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe return envelope } -func (e *Engine) recordDecision(decision api.DecisionEnvelope) { +func (e *Engine) recordDecision(decision api.DecisionEnvelope) error { e.mu.Lock() defer e.mu.Unlock() e.history[decision.ID] = decision + if e.log != nil { + return e.log.Append(decision) + } + return nil } func (e *Engine) caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata { diff --git a/internal/decision/engine_test.go b/internal/decision/engine_test.go index e7b44aa..00c3e56 100644 --- a/internal/decision/engine_test.go +++ b/internal/decision/engine_test.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/netkingdom/flex-auth/internal/audit" "github.com/netkingdom/flex-auth/internal/decision" "github.com/netkingdom/flex-auth/internal/policy" "github.com/netkingdom/flex-auth/internal/registry" @@ -188,6 +189,40 @@ func TestExplainUsesRecordedDecision(t *testing.T) { } } +func TestCheckWritesDecisionLog(t *testing.T) { + engine := newTestEngine(t) + log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "decisions.jsonl")) + engine.SetDecisionLog(log) + + got, err := engine.Check(context.Background(), api.CheckRequest{ + ID: "check:logged-deny", + Subject: api.SubjectRef{ID: "user:alice"}, + Action: "read", + Resource: api.ResourceRef{ + ID: "document:missing", + Type: "document", + System: "markitect-tool", + }, + }) + if err != nil { + t.Fatalf("Check: %v", err) + } + if got.Effect != api.DecisionEffectDeny { + t.Fatalf("got.Effect = %q; want deny", got.Effect) + } + + decisions, err := log.ReadAll() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + if len(decisions) != 1 { + t.Fatalf("len(decisions) = %d; want 1", len(decisions)) + } + if decisions[0].ID != got.ID || decisions[0].Effect != api.DecisionEffectDeny { + t.Fatalf("logged decision = %+v; want logged deny %s", decisions[0], got.ID) + } +} + func newTestEngine(t *testing.T) *decision.Engine { t.Helper() diff --git a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md index 0f40e8f..6261ad8 100644 --- a/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md +++ b/workplans/FLEX-WP-0002-standalone-policy-as-code-core.md @@ -195,7 +195,7 @@ blocked." ```task id: FLEX-WP-0002-T006 -status: todo +status: done priority: medium state_hub_task_id: "2def10c1-4b5f-44a8-8e6b-4c8592fffd43" ```