Withhold uncommitted decision effects and pin accepted layer canon
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
f5fad7c285
commit
22185e94ec
12 changed files with 269 additions and 65 deletions
|
|
@ -43,22 +43,22 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// GH-DEC-2026-024: a restriction is not exempt from required commitment.
|
||||
func TestUncommittedDenyIsWithheld(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 !errors.Is(err, decision.ErrRecordNotCommitted) || got.ID != "" {
|
||||
t.Fatalf("Check = %+v, %v; want unavailable and no decision", got, err)
|
||||
}
|
||||
if got.Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("effect = %q; want deny", got.Effect)
|
||||
if len(recorder.uncommitted) != 0 {
|
||||
t.Fatalf("withheld restriction counted as released: %v", recorder.uncommitted)
|
||||
}
|
||||
if len(recorder.uncommitted) != 1 || recorder.uncommitted[0] != api.DecisionEffectDeny {
|
||||
t.Fatalf("released_uncommitted = %v; want [deny]", recorder.uncommitted)
|
||||
engine.SetDecisionLog(nil)
|
||||
committed, err := engine.Check(context.Background(), deniedRead)
|
||||
if err != nil || committed.Effect != api.DecisionEffectDeny {
|
||||
t.Fatalf("normal deny = %+v, %v", committed, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +99,19 @@ func TestBatchCommitsOnceAndWithholdsOnAnyUncommittedAllow(t *testing.T) {
|
|||
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)
|
||||
if !errors.Is(err, decision.ErrRecordNotCommitted) || len(decisions) != 0 {
|
||||
t.Fatalf("restriction-only batch: decisions=%v err=%v; want withheld", decisions, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAllowedWithholdsWhenAllCandidateDenialsFailCommit(t *testing.T) {
|
||||
engine := newTestEngine(t)
|
||||
recorder := &failingRecorder{}
|
||||
engine.SetDecisionLog(recorder)
|
||||
got, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{
|
||||
Subject: deniedRead.Subject, Action: "unknown-action", System: "markitect-tool",
|
||||
})
|
||||
if !errors.Is(err, decision.ErrRecordNotCommitted) || got != nil || recorder.batches != 1 {
|
||||
t.Fatalf("ListAllowed = %v, %v; batches=%d; want unavailable", got, err, recorder.batches)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
internal/decision/emission_release_test.go
Normal file
51
internal/decision/emission_release_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package decision
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/access-engine/pkg/api"
|
||||
)
|
||||
|
||||
type commitProbe struct {
|
||||
fail bool
|
||||
committed []api.DecisionEnvelope
|
||||
released int
|
||||
}
|
||||
|
||||
func (p *commitProbe) Append(d api.DecisionEnvelope) error {
|
||||
if p.fail {
|
||||
return errors.New("disk unavailable")
|
||||
}
|
||||
p.committed = append(p.committed, d)
|
||||
return nil
|
||||
}
|
||||
func (p *commitProbe) NoteReleasedUncommitted(api.DecisionEffect) { p.released++ }
|
||||
|
||||
// Every source-owned emission class is load-bearing. An outbox failure must
|
||||
// hide every effect from both the response and Explain, including restrictions.
|
||||
func TestRequiredCommitForEveryEffect(t *testing.T) {
|
||||
for _, effect := range api.DecisionEffects() {
|
||||
t.Run(string(effect), func(t *testing.T) {
|
||||
probe := &commitProbe{fail: true}
|
||||
engine := &Engine{history: make(map[string]api.DecisionEnvelope), log: probe}
|
||||
d := api.DecisionEnvelope{ID: "decision", Effect: effect}
|
||||
if err := engine.recordDecisions([]api.DecisionEnvelope{d}); !errors.Is(err, ErrRecordNotCommitted) {
|
||||
t.Fatalf("commit failure = %v; want unavailable", err)
|
||||
}
|
||||
if _, err := engine.Explain(d.ID); err == nil {
|
||||
t.Fatal("uncommitted decision exposed by Explain")
|
||||
}
|
||||
if probe.released != 0 || len(probe.committed) != 0 {
|
||||
t.Fatal("failed commit claimed a release")
|
||||
}
|
||||
probe.fail = false
|
||||
if err := engine.recordDecisions([]api.DecisionEnvelope{d}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := engine.Explain(d.ID); err != nil || got.Effect != effect {
|
||||
t.Fatalf("committed explanation = %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -42,15 +42,9 @@ 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).
|
||||
// ErrRecordNotCommitted means a decision was withheld because its required
|
||||
// record did not commit (GH-DEC-2026-024). The service answers 503; this is
|
||||
// unavailability, never a substitute authorization verdict.
|
||||
var ErrRecordNotCommitted = errors.New("decision withheld: record not committed")
|
||||
|
||||
// ListAllowedRequest describes a deterministic list_allowed call.
|
||||
|
|
@ -134,8 +128,7 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
|
|||
}
|
||||
|
||||
// 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).
|
||||
// released only after its required record commits (GH-DEC-2026-024).
|
||||
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
|
||||
decision, err := e.decide(ctx, request)
|
||||
if err != nil {
|
||||
|
|
@ -441,34 +434,22 @@ func callerProvenance(ctx context.Context) *api.CallerProvenance {
|
|||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
// recordDecisions releases no effect until its required record commits.
|
||||
// GH-DEC-2026-024 refuses the former restrictive-effect failure exception.
|
||||
// Failed decisions must not enter Explain's history as if they were released.
|
||||
func (e *Engine) recordDecisions(decisions []api.DecisionEnvelope) error {
|
||||
e.mu.Lock()
|
||||
for _, decision := range decisions {
|
||||
e.history[decision.ID] = decision
|
||||
}
|
||||
e.mu.RLock()
|
||||
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() {
|
||||
e.mu.RUnlock()
|
||||
if log != nil && len(decisions) != 0 {
|
||||
if err := appendAll(log, decisions); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrRecordNotCommitted, err)
|
||||
}
|
||||
}
|
||||
if noter, ok := log.(UncommittedNoter); ok {
|
||||
for _, decision := range decisions {
|
||||
noter.NoteReleasedUncommitted(decision.Effect)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for _, decision := range decisions {
|
||||
e.history[decision.ID] = decision
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue