generated from coulomb/repo-seed
Add local decision log
This commit is contained in:
@@ -1,6 +1,3 @@
|
|||||||
// Package audit persists compact decision envelopes. Denies, redactions,
|
// Package audit persists compact decision envelopes. Denies, redactions,
|
||||||
// exports, and emergency actions are always recorded; allows may be
|
// exports, and emergency actions are always recorded; allows may be sampled.
|
||||||
// sampled.
|
|
||||||
//
|
|
||||||
// Implementation lands in FLEX-WP-0002 P2.6.
|
|
||||||
package audit
|
package audit
|
||||||
|
|||||||
94
internal/audit/log.go
Normal file
94
internal/audit/log.go
Normal 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
|
||||||
|
}
|
||||||
59
internal/audit/log_test.go
Normal file
59
internal/audit/log_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ type Engine struct {
|
|||||||
policy *policy.Package
|
policy *policy.Package
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
history map[string]api.DecisionEnvelope
|
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.
|
// ListAllowedRequest describes a deterministic list_allowed call.
|
||||||
@@ -69,6 +75,13 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e
|
|||||||
}, nil
|
}, 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.
|
// Check evaluates one subject/action/resource request.
|
||||||
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||||
normalized, facts := e.normalizeRequest(request)
|
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)
|
decision := e.envelope(normalized, expectation, facts)
|
||||||
e.recordDecision(decision)
|
if err := e.recordDecision(decision); err != nil {
|
||||||
|
return api.DecisionEnvelope{}, err
|
||||||
|
}
|
||||||
return decision, nil
|
return decision, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,10 +301,14 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
|
|||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) recordDecision(decision api.DecisionEnvelope) {
|
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
e.history[decision.ID] = decision
|
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 {
|
func (e *Engine) caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/netkingdom/flex-auth/internal/audit"
|
||||||
"github.com/netkingdom/flex-auth/internal/decision"
|
"github.com/netkingdom/flex-auth/internal/decision"
|
||||||
"github.com/netkingdom/flex-auth/internal/policy"
|
"github.com/netkingdom/flex-auth/internal/policy"
|
||||||
"github.com/netkingdom/flex-auth/internal/registry"
|
"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 {
|
func newTestEngine(t *testing.T) *decision.Engine {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ blocked."
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0002-T006
|
id: FLEX-WP-0002-T006
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "2def10c1-4b5f-44a8-8e6b-4c8592fffd43"
|
state_hub_task_id: "2def10c1-4b5f-44a8-8e6b-4c8592fffd43"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user