fix(auth): sign served decisions before recording and returning them
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
This commit is contained in:
parent
62ac2b4e99
commit
ec21518e25
11 changed files with 361 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
64
internal/decision/envelope_signing_test.go
Normal file
64
internal/decision/envelope_signing_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue