Finish FLEX-WP-0019 layer-model v0.7 conformance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 57s

Close the remaining PDP obligations: mechanical layer declaration check,
registry-snapshot digest in provenance, explicit allow TTL, per-input-class
freshness deadlines, and the published decision-record contract. Document
the canonical request digest as the §6.4.2 replay test.

Assistant: grok
Assistant-Session: 01a06256-fb71-7102-b3a9-27e6734257d0
This commit is contained in:
tegwick 2026-09-03 23:48:45 +02:00
parent 9689894c15
commit 56940727bf
32 changed files with 1194 additions and 111 deletions

View file

@ -10,6 +10,7 @@ import (
"slices"
"strings"
"sync"
"time"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
@ -24,6 +25,7 @@ type Engine struct {
mu sync.RWMutex
history map[string]api.DecisionEnvelope
log DecisionRecorder
clock func() time.Time
}
// DecisionRecorder persists decision envelopes.
@ -82,6 +84,23 @@ func (e *Engine) SetDecisionLog(log DecisionRecorder) {
e.log = log
}
// SetClock overrides the engine clock. Tests use this to pin allow lifetimes.
func (e *Engine) SetClock(clock func() time.Time) {
e.mu.Lock()
defer e.mu.Unlock()
e.clock = clock
}
func (e *Engine) now() time.Time {
e.mu.RLock()
clock := e.clock
e.mu.RUnlock()
if clock != nil {
return clock().UTC()
}
return time.Now().UTC()
}
// Check evaluates one subject/action/resource request.
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
normalized, facts := e.normalizeRequest(request)
@ -307,13 +326,19 @@ func (e *Engine) envelope(request api.CheckRequest, expectation api.DecisionExpe
"matched_relationship": facts.matchedRelationship,
},
Provenance: api.DecisionProvenance{
Evaluator: "flex-auth/local",
Mode: "standalone",
PolicyPackage: e.policy.Metadata.ID,
PolicyVersion: e.policy.Metadata.Version,
Evaluator: "flex-auth/local",
Mode: "standalone",
PolicyPackage: e.policy.Metadata.ID,
PolicyVersion: e.policy.Metadata.Version,
PolicyPackageDigest: e.policy.Digest(),
RegistrySnapshotDigest: e.store.Digest(),
},
Caring: e.caringDecisionMetadata(facts.descriptor, expectation.ConformanceFindings),
}
api.CompleteDecision(&envelope, request, api.DecisionCompletion{
AllowTTL: e.policy.Metadata.AllowTTL,
Now: e.now(),
})
envelope.ID = decisionID(e.policy.Metadata, request, envelope)
return envelope
}

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
@ -198,6 +199,112 @@ func TestExplainUsesRecordedDecision(t *testing.T) {
}
}
func TestCheckRecordsRegistrySnapshotDigest(t *testing.T) {
store := newTestStore(t)
engine := newTestEngineWithStore(t, store)
want := store.Digest()
first, err := engine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if first.Provenance.RegistrySnapshotDigest != want {
t.Fatalf("digest = %q; want %q", first.Provenance.RegistrySnapshotDigest, want)
}
if first.Provenance.PolicyPackageDigest == "" || !strings.HasPrefix(first.Provenance.PolicyPackageDigest, "sha256:") {
t.Fatalf("policy package digest = %q", first.Provenance.PolicyPackageDigest)
}
second, err := engine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check again: %v", err)
}
if second.Provenance.RegistrySnapshotDigest != want {
t.Fatal("two decisions over the same snapshot disagree on digest")
}
if err := store.ImportResourceManifest(api.ResourceManifest{
ID: "markitect-extra",
System: "markitect-tool",
Resources: []api.Resource{
{ID: "document:other-note", Type: "document"},
},
}); err != nil {
t.Fatalf("ImportResourceManifest: %v", err)
}
changedEngine := newTestEngineWithStore(t, store)
changed, err := changedEngine.Check(context.Background(), api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check after snapshot change: %v", err)
}
if changed.Provenance.RegistrySnapshotDigest == want {
t.Fatal("changed snapshot kept the same digest")
}
}
func TestCheckAllowLifetimes(t *testing.T) {
fixed := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
request := api.CheckRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", System: "markitect-tool"},
}
defaulted := newTestEngine(t)
defaulted.SetClock(func() time.Time { return fixed })
got, err := defaulted.Check(context.Background(), request)
if err != nil {
t.Fatalf("default Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Lifetime == nil || got.Lifetime.TTL != "15m" {
t.Fatalf("defaulted allow = %+v", got)
}
if got.Lifetime.ExpiresAt != "2026-08-29T12:15:00Z" {
t.Fatalf("default expires_at = %q", got.Lifetime.ExpiresAt)
}
if got.ContractVersion != api.DecisionRecordContractV1 {
t.Fatalf("contract_version = %q", got.ContractVersion)
}
declared := newTestEngineWithTTL(t, "5m")
declared.SetClock(func() time.Time { return fixed })
got, err = declared.Check(context.Background(), request)
if err != nil {
t.Fatalf("declared Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Lifetime == nil || got.Lifetime.TTL != "5m" {
t.Fatalf("declared allow = %+v", got)
}
if got.Lifetime.ExpiresAt != "2026-08-29T12:05:00Z" {
t.Fatalf("declared expires_at = %q", got.Lifetime.ExpiresAt)
}
unstated := newTestEngineWithTTL(t, "none")
unstated.SetClock(func() time.Time { return fixed })
got, err = unstated.Check(context.Background(), request)
if err != nil {
t.Fatalf("unstated Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != api.ReasonAllowLifetimeUnstated {
t.Fatalf("unstated allow = %s/%s; want deny/%s", got.Effect, got.Reason, api.ReasonAllowLifetimeUnstated)
}
if got.Lifetime != nil {
t.Fatalf("unstated deny still has lifetime %+v", got.Lifetime)
}
}
func TestCheckWritesDecisionLog(t *testing.T) {
engine := newTestEngine(t)
log := audit.NewJSONLDecisionLog(filepath.Join(t.TempDir(), "decisions.jsonl"))
@ -240,11 +347,22 @@ func newTestEngine(t *testing.T) *decision.Engine {
func newTestEngineWithStore(t *testing.T, store *registry.Store) *decision.Engine {
t.Helper()
return newTestEngineWithStoreAndTTL(t, store, "")
}
func newTestEngineWithTTL(t *testing.T, allowTTL string) *decision.Engine {
t.Helper()
return newTestEngineWithStoreAndTTL(t, newTestStore(t), allowTTL)
}
func newTestEngineWithStoreAndTTL(t *testing.T, store *registry.Store, allowTTL string) *decision.Engine {
t.Helper()
policyPackage, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "caring", "policy_package.md"))
if err != nil {
t.Fatalf("LoadAndValidateFile policy: %v", err)
}
policyPackage.Metadata.AllowTTL = allowTTL
engine, err := decision.NewEngine(store, policyPackage)
if err != nil {
t.Fatalf("NewEngine: %v", err)