fix(auth): sign served decisions before recording and returning them
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Failing after 1m27s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
tegwick 2026-10-16 07:51:43 +02:00
parent 62ac2b4e99
commit ec21518e25
11 changed files with 361 additions and 14 deletions

View file

@ -3,6 +3,7 @@ package decision
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@ -18,18 +19,21 @@ import (
"github.com/netkingdom/access-engine/internal/callerauth"
"github.com/netkingdom/access-engine/internal/policy"
"github.com/netkingdom/access-engine/internal/registry"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
// Engine evaluates deterministic standalone authorization checks against a
// local registry and one validated policy package.
type Engine struct {
store *registry.Store
policy *policy.Package
mu sync.RWMutex
history map[string]api.DecisionEnvelope
log DecisionRecorder
clock func() time.Time
store *registry.Store
policy *policy.Package
mu sync.RWMutex
history map[string]api.DecisionEnvelope
log DecisionRecorder
clock func() time.Time
signingKey ed25519.PrivateKey
signingKID string
}
// DecisionRecorder persists decision envelopes.
@ -91,6 +95,19 @@ func NewEngine(store *registry.Store, policyPackage *policy.Package) (*Engine, e
}, nil
}
// SetEnvelopeSigningKey installs an owner-custodied key before serving requests.
// The key is copied so caller mutation cannot change the admitted signer.
func (e *Engine) SetEnvelopeSigningKey(key ed25519.PrivateKey, kid string) error {
if len(key) != ed25519.PrivateKeySize || strings.TrimSpace(kid) == "" {
return fmt.Errorf("valid Ed25519 private key and kid are required")
}
e.mu.Lock()
defer e.mu.Unlock()
e.signingKey = append(ed25519.PrivateKey(nil), key...)
e.signingKID = kid
return nil
}
// SetDecisionLog attaches a local decision recorder to the engine.
func (e *Engine) SetDecisionLog(log DecisionRecorder) {
e.mu.Lock()
@ -124,7 +141,16 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
return api.DecisionEnvelope{}, err
}
return e.envelope(ctx, normalized, request, expectation, facts), nil
envelope := e.envelope(ctx, normalized, request, expectation, facts)
e.mu.RLock()
key, kid := e.signingKey, e.signingKID
e.mu.RUnlock()
if len(key) != 0 {
if err := sign.Sign(&envelope, key, kid); err != nil {
return api.DecisionEnvelope{}, fmt.Errorf("decision signing failed: %w", err)
}
}
return envelope, nil
}
// Check evaluates one subject/action/resource request. The decision is

View file

@ -0,0 +1,64 @@
package decision_test
import (
"context"
"crypto/ed25519"
"path/filepath"
"reflect"
"testing"
"github.com/netkingdom/access-engine/internal/sign"
"github.com/netkingdom/access-engine/pkg/api"
)
type signingRecorder struct{ records []api.DecisionEnvelope }
func (r *signingRecorder) Append(e api.DecisionEnvelope) error {
r.records = append(r.records, e)
return nil
}
func TestSignerAuthenticatesReturnedAndRecordedEnvelope(t *testing.T) {
engine := newTestEngine(t)
var request api.CheckRequest
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
unsigned, err := engine.Check(context.Background(), request)
if err != nil {
t.Fatal(err)
}
// Synthetic deterministic seed only; no estate credential is read.
key := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize))
public := append(ed25519.PublicKey(nil), key.Public().(ed25519.PublicKey)...)
if err := engine.SetEnvelopeSigningKey(key, "synthetic-current"); err != nil {
t.Fatal(err)
}
key[0] ^= 1 // Setter must not retain the caller's mutable key slice.
recorder := &signingRecorder{}
engine.SetDecisionLog(recorder)
signed, err := engine.Check(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err := sign.Verify(signed, map[string]ed25519.PublicKey{"synthetic-current": public}); err != nil {
t.Fatal(err)
}
if len(recorder.records) != 1 || !reflect.DeepEqual(signed, recorder.records[0]) {
t.Fatal("recorded envelope differs from authenticated return")
}
if signed.Binding.RequestDigest != unsigned.Binding.RequestDigest {
t.Fatal("signing changed request digest")
}
signed.Provenance.PolicyVersion = "altered"
if sign.Verify(signed, map[string]ed25519.PublicKey{"synthetic-current": public}) == nil {
t.Fatal("altered provenance verified")
}
}
func TestSignerRejectsInvalidConfiguration(t *testing.T) {
engine := newTestEngine(t)
if engine.SetEnvelopeSigningKey(nil, "kid") == nil {
t.Fatal("missing key accepted")
}
if engine.SetEnvelopeSigningKey(ed25519.NewKeyFromSeed(make([]byte, 32)), "") == nil {
t.Fatal("missing kid accepted")
}
}