FLEX-WP-0031-T03: durable decision outbox and the FLEX-DEC-2026-018 release rule
internal/emission commits one audit-core-shaped event per decision with fsync before release (one sync per batch), random event ids with the decision id as correlation_id, torn-tail and failed-commit truncation, and per-class committed/released_uncommitted counts at GET /v1/emission. The engine releases restrictions whose record failed to commit and withholds allow/audit_only (503). api.DecisionEffects() is pinned by a source-parsing test and cadence.yaml must classify exactly it. T03 split under the task budget: heartbeat+drain is T05, reconciliation, profile check and PVC are T06. Nothing deployed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 307130@bnt-lap001 Assistant-Session: 270c79f7-0823-4b0d-990d-aad5af9935ce
This commit is contained in:
parent
7f0e2e37f9
commit
cd14a34e33
9 changed files with 842 additions and 23 deletions
105
internal/decision/emission_atomicity_test.go
Normal file
105
internal/decision/emission_atomicity_test.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package decision_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/decision"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// failingRecorder is an outbox whose commit always fails.
|
||||
type failingRecorder struct {
|
||||
appends int
|
||||
batches int
|
||||
uncommitted []api.DecisionEffect
|
||||
}
|
||||
|
||||
func (r *failingRecorder) Append(api.DecisionEnvelope) error {
|
||||
r.appends++
|
||||
return errors.New("disk full")
|
||||
}
|
||||
|
||||
func (r *failingRecorder) AppendBatch([]api.DecisionEnvelope) error {
|
||||
r.batches++
|
||||
return errors.New("disk full")
|
||||
}
|
||||
|
||||
func (r *failingRecorder) NoteReleasedUncommitted(effect api.DecisionEffect) {
|
||||
r.uncommitted = append(r.uncommitted, effect)
|
||||
}
|
||||
|
||||
var (
|
||||
allowedRead = api.CheckRequest{
|
||||
Subject: api.SubjectRef{ID: "user:alice"},
|
||||
Action: "read",
|
||||
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
|
||||
}
|
||||
deniedRead = api.CheckRequest{
|
||||
Subject: api.SubjectRef{ID: "user:alice"},
|
||||
Action: "read",
|
||||
Resource: api.ResourceRef{ID: "document:missing", Type: "document", System: "markitect-tool"},
|
||||
}
|
||||
)
|
||||
|
||||
// FLEX-DEC-2026-018: withholding a deny turns it into an error an open-stance
|
||||
// consumer reads as proceed, so a restriction is released and counted.
|
||||
func TestUncommittedDenyIsReleasedAndCounted(t *testing.T) {
|
||||
engine := newTestEngine(t)
|
||||
recorder := &failingRecorder{}
|
||||
engine.SetDecisionLog(recorder)
|
||||
|
||||
got, err := engine.Check(context.Background(), deniedRead)
|
||||
if err != nil {
|
||||
t.Fatalf("Check: %v; a restriction must never be withheld", err)
|
||||
}
|
||||
if got.Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("effect = %q; want deny", got.Effect)
|
||||
}
|
||||
if len(recorder.uncommitted) != 1 || recorder.uncommitted[0] != api.DecisionEffectDeny {
|
||||
t.Fatalf("released_uncommitted = %v; want [deny]", recorder.uncommitted)
|
||||
}
|
||||
}
|
||||
|
||||
// FLEX-DEC-2026-018: authority is never granted without its evidence.
|
||||
func TestUncommittedAllowIsWithheld(t *testing.T) {
|
||||
engine := newTestEngine(t)
|
||||
recorder := &failingRecorder{}
|
||||
engine.SetDecisionLog(recorder)
|
||||
|
||||
got, err := engine.Check(context.Background(), allowedRead)
|
||||
if !errors.Is(err, decision.ErrRecordNotCommitted) {
|
||||
t.Fatalf("err = %v, decision = %+v; want ErrRecordNotCommitted", err, got)
|
||||
}
|
||||
if len(recorder.uncommitted) != 0 {
|
||||
t.Fatalf("a withheld allow was counted as released: %v", recorder.uncommitted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCommitsOnceAndWithholdsOnAnyUncommittedAllow(t *testing.T) {
|
||||
engine := newTestEngine(t)
|
||||
recorder := &failingRecorder{}
|
||||
engine.SetDecisionLog(recorder)
|
||||
|
||||
_, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{
|
||||
Subject: allowedRead.Subject,
|
||||
Action: "read",
|
||||
Resources: []api.ResourceRef{allowedRead.Resource, deniedRead.Resource},
|
||||
})
|
||||
if !errors.Is(err, decision.ErrRecordNotCommitted) {
|
||||
t.Fatalf("err = %v; want ErrRecordNotCommitted for a batch holding an allow", err)
|
||||
}
|
||||
if recorder.batches != 1 || recorder.appends != 0 {
|
||||
t.Fatalf("batches=%d appends=%d; want one batch commit", recorder.batches, recorder.appends)
|
||||
}
|
||||
|
||||
decisions, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{
|
||||
Subject: deniedRead.Subject,
|
||||
Action: "read",
|
||||
Resources: []api.ResourceRef{deniedRead.Resource},
|
||||
})
|
||||
if err != nil || len(decisions) != 1 {
|
||||
t.Fatalf("restriction-only batch: decisions=%v err=%v; want it released", decisions, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
|
@ -36,6 +37,22 @@ type DecisionRecorder interface {
|
|||
Append(api.DecisionEnvelope) error
|
||||
}
|
||||
|
||||
// BatchRecorder commits several decisions in one durable write.
|
||||
type BatchRecorder interface {
|
||||
AppendBatch([]api.DecisionEnvelope) error
|
||||
}
|
||||
|
||||
// UncommittedNoter counts restrictions released after their record failed to
|
||||
// commit (FLEX-DEC-2026-018).
|
||||
type UncommittedNoter interface {
|
||||
NoteReleasedUncommitted(api.DecisionEffect)
|
||||
}
|
||||
|
||||
// ErrRecordNotCommitted means a permissive decision was withheld because its
|
||||
// record did not commit. The service answers 503, and the consumer's declared
|
||||
// stance applies (FLEX-DEC-2026-018).
|
||||
var ErrRecordNotCommitted = errors.New("decision withheld: record not committed")
|
||||
|
||||
// ListAllowedRequest describes a deterministic list_allowed call.
|
||||
type ListAllowedRequest struct {
|
||||
Subject api.SubjectRef `json:"subject"`
|
||||
|
|
@ -104,8 +121,8 @@ func (e *Engine) now() time.Time {
|
|||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// Check evaluates one subject/action/resource request.
|
||||
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
// decide evaluates one request without recording it.
|
||||
func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
normalized, facts := e.normalizeRequest(request)
|
||||
|
||||
expectation, err := e.policy.Evaluate(ctx, normalized)
|
||||
|
|
@ -113,19 +130,29 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
|
|||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
|
||||
decision := e.envelope(ctx, normalized, request, expectation, facts)
|
||||
if err := e.recordDecision(decision); err != nil {
|
||||
return e.envelope(ctx, normalized, request, expectation, facts), nil
|
||||
}
|
||||
|
||||
// Check evaluates one subject/action/resource request. The decision is
|
||||
// released only after its record commits, except that a restriction is never
|
||||
// withheld (FLEX-DEC-2026-018).
|
||||
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
decision, err := e.decide(ctx, request)
|
||||
if err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
if err := e.recordDecisions([]api.DecisionEnvelope{decision}); err != nil {
|
||||
return api.DecisionEnvelope{}, err
|
||||
}
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// BatchCheck evaluates one subject/action/context tuple against resources in
|
||||
// request order.
|
||||
// request order, and commits the whole batch in one durable write.
|
||||
func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
|
||||
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
|
||||
for _, resource := range request.Resources {
|
||||
decision, err := e.Check(ctx, api.CheckRequest{
|
||||
decision, err := e.decide(ctx, api.CheckRequest{
|
||||
ID: request.ID,
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
|
|
@ -139,18 +166,21 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest)
|
|||
}
|
||||
decisions = append(decisions, decision)
|
||||
}
|
||||
if err := e.recordDecisions(decisions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decisions, nil
|
||||
}
|
||||
|
||||
// ListAllowed evaluates candidate resources and returns only allow decisions.
|
||||
func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) {
|
||||
candidates := e.store.ResourceRefs(request.System, request.ResourceType)
|
||||
allowed := make([]api.DecisionEnvelope, 0, len(candidates))
|
||||
decisions := make([]api.DecisionEnvelope, 0, len(candidates))
|
||||
for _, resource := range candidates {
|
||||
if !resourceMatchesFilters(resource, request.Filters) {
|
||||
continue
|
||||
}
|
||||
decision, err := e.Check(ctx, api.CheckRequest{
|
||||
decision, err := e.decide(ctx, api.CheckRequest{
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: resource,
|
||||
|
|
@ -160,6 +190,13 @@ func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decisions = append(decisions, decision)
|
||||
}
|
||||
if err := e.recordDecisions(decisions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := make([]api.DecisionEnvelope, 0, len(decisions))
|
||||
for _, decision := range decisions {
|
||||
if decision.Effect == api.DecisionEffectAllow {
|
||||
allowed = append(allowed, decision)
|
||||
}
|
||||
|
|
@ -404,12 +441,46 @@ func callerProvenance(ctx context.Context) *api.CallerProvenance {
|
|||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
|
||||
// recordDecisions commits decisions and applies FLEX-DEC-2026-018 when the
|
||||
// commit fails: restrictions are released and counted, and any permissive
|
||||
// decision withholds the call. In a batch that withholds the batch's
|
||||
// restrictions too; no live consumer calls batch_check (user-engine loops
|
||||
// /v1/check), and the residual is stated in the ruling.
|
||||
func (e *Engine) recordDecisions(decisions []api.DecisionEnvelope) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history[decision.ID] = decision
|
||||
if e.log != nil {
|
||||
return e.log.Append(decision)
|
||||
for _, decision := range decisions {
|
||||
e.history[decision.ID] = decision
|
||||
}
|
||||
log := e.log
|
||||
e.mu.Unlock()
|
||||
if log == nil || len(decisions) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := appendAll(log, decisions)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
for _, decision := range decisions {
|
||||
if !decision.Effect.Restricts() {
|
||||
return fmt.Errorf("%w: %v", ErrRecordNotCommitted, err)
|
||||
}
|
||||
}
|
||||
if noter, ok := log.(UncommittedNoter); ok {
|
||||
for _, decision := range decisions {
|
||||
noter.NoteReleasedUncommitted(decision.Effect)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendAll(log DecisionRecorder, decisions []api.DecisionEnvelope) error {
|
||||
if batch, ok := log.(BatchRecorder); ok {
|
||||
return batch.AppendBatch(decisions)
|
||||
}
|
||||
for _, decision := range decisions {
|
||||
if err := log.Append(decision); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue