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
393 lines
13 KiB
Go
393 lines
13 KiB
Go
package decision_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/netkingdom/flex-auth/internal/audit"
|
|
"github.com/netkingdom/flex-auth/internal/decision"
|
|
"github.com/netkingdom/flex-auth/internal/policy"
|
|
"github.com/netkingdom/flex-auth/internal/registry"
|
|
"github.com/netkingdom/flex-auth/pkg/api"
|
|
)
|
|
|
|
func TestCheckUsesExplicitCaringContext(t *testing.T) {
|
|
engine := newTestEngine(t)
|
|
|
|
var request api.CheckRequest
|
|
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
|
|
|
|
got, err := engine.Check(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
again, err := engine.Check(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Check again: %v", err)
|
|
}
|
|
|
|
if got.ID != again.ID {
|
|
t.Fatalf("decision id is not deterministic: %q != %q", got.ID, again.ID)
|
|
}
|
|
if got.Effect != api.DecisionEffectAllow {
|
|
t.Fatalf("got.Effect = %q; want allow", got.Effect)
|
|
}
|
|
if got.Reason != "reader_relation" {
|
|
t.Errorf("got.Reason = %q; want reader_relation", got.Reason)
|
|
}
|
|
if got.MatchedPolicyVersion != "v1" {
|
|
t.Errorf("got.MatchedPolicyVersion = %q; want v1", got.MatchedPolicyVersion)
|
|
}
|
|
if got.Subject.Type != api.SubjectTypeHuman || got.Subject.Attributes["groups"] == nil {
|
|
t.Errorf("got.Subject = %+v; want enriched human subject with groups", got.Subject)
|
|
}
|
|
if got.Resource.Type != "document" || got.Resource.Attributes["trust_zone"] != "internal" {
|
|
t.Errorf("got.Resource = %+v; want enriched document resource", got.Resource)
|
|
}
|
|
if got.Binding == nil {
|
|
t.Fatal("got.Binding is nil")
|
|
}
|
|
if got.Binding.Action != request.Action || got.Binding.Subject.ID != got.Subject.ID || got.Binding.Resource.ID != got.Resource.ID {
|
|
t.Errorf("got.Binding = %+v; want normalized action, subject, and resource", got.Binding)
|
|
}
|
|
if !strings.HasPrefix(got.Binding.RequestDigest, "sha256:") || len(got.Binding.RequestDigest) != len("sha256:")+64 {
|
|
t.Errorf("got.Binding.RequestDigest = %q; want full SHA-256 digest", got.Binding.RequestDigest)
|
|
}
|
|
if got.Caring == nil || got.Caring.Descriptor == nil {
|
|
t.Fatal("got.Caring.Descriptor is nil")
|
|
}
|
|
if got.Caring.Descriptor.ID != "descriptor:tenant-alpha-document-reader" {
|
|
t.Errorf("got.Caring.Descriptor.ID = %q", got.Caring.Descriptor.ID)
|
|
}
|
|
if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked {
|
|
t.Errorf("got.Caring.RestrictionsEvaluated = %v; want [ExportBlocked]", got.Caring.RestrictionsEvaluated)
|
|
}
|
|
}
|
|
|
|
func TestCheckMatchesRegistryRelationshipDescriptor(t *testing.T) {
|
|
engine := newTestEngine(t)
|
|
|
|
got, err := engine.Check(context.Background(), api.CheckRequest{
|
|
ID: "check:registry-descriptor",
|
|
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 got.Effect != api.DecisionEffectAllow {
|
|
t.Fatalf("got.Effect = %q; want allow", got.Effect)
|
|
}
|
|
if got.Caring == nil || got.Caring.Descriptor == nil {
|
|
t.Fatal("got.Caring.Descriptor is nil")
|
|
}
|
|
if got.Caring.Descriptor.SubjectType != api.SubjectTypeGroup {
|
|
t.Errorf("got.Caring.Descriptor.SubjectType = %q; want Group", got.Caring.Descriptor.SubjectType)
|
|
}
|
|
if got.Diagnostics["matched_relationship"] != "rel:alice-reader-internal-note" {
|
|
t.Errorf("matched_relationship = %v", got.Diagnostics["matched_relationship"])
|
|
}
|
|
}
|
|
|
|
func TestBatchCheckPreservesResourceOrder(t *testing.T) {
|
|
engine := newTestEngine(t)
|
|
|
|
got, err := engine.BatchCheck(context.Background(), api.BatchCheckRequest{
|
|
ID: "batch:read-documents",
|
|
Subject: api.SubjectRef{
|
|
ID: "user:alice",
|
|
},
|
|
Action: "read",
|
|
Resources: []api.ResourceRef{
|
|
{ID: "document:internal-note", System: "markitect-tool"},
|
|
{ID: "document:missing", Type: "document", System: "markitect-tool"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BatchCheck: %v", err)
|
|
}
|
|
|
|
if len(got) != 2 {
|
|
t.Fatalf("len(got) = %d; want 2", len(got))
|
|
}
|
|
if got[0].Resource.ID != "document:internal-note" || got[0].Effect != api.DecisionEffectAllow {
|
|
t.Fatalf("first decision = %+v; want allow for document:internal-note", got[0])
|
|
}
|
|
if got[1].Resource.ID != "document:missing" || got[1].Effect != api.DecisionEffectDeny {
|
|
t.Fatalf("second decision = %+v; want deny for document:missing", got[1])
|
|
}
|
|
if got[0].ID == got[1].ID {
|
|
t.Fatalf("batch decisions have duplicate deterministic ids: %q", got[0].ID)
|
|
}
|
|
if got[1].Caring == nil || len(got[1].Caring.ConformanceFindings) == 0 {
|
|
t.Fatal("missing descriptor deny should carry a CARING conformance finding")
|
|
}
|
|
}
|
|
|
|
func TestListAllowedReturnsOnlyAllowedResources(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if err := store.ImportResourceManifest(api.ResourceManifest{
|
|
ID: "markitect-extra-documents",
|
|
System: "markitect-tool",
|
|
Resources: []api.Resource{
|
|
{ID: "document:public-note", Type: "document", TrustZone: "public"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("ImportResourceManifest: %v", err)
|
|
}
|
|
engine := newTestEngineWithStore(t, store)
|
|
|
|
got, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{
|
|
Subject: api.SubjectRef{ID: "user:alice"},
|
|
Action: "read",
|
|
System: "markitect-tool",
|
|
Filters: map[string]any{
|
|
"resource_type": "document",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListAllowed: %v", err)
|
|
}
|
|
|
|
if len(got) != 1 {
|
|
t.Fatalf("len(got) = %d; want one allowed resource: %+v", len(got), got)
|
|
}
|
|
if got[0].Resource.ID != "document:internal-note" || got[0].Effect != api.DecisionEffectAllow {
|
|
t.Fatalf("allowed decision = %+v; want document:internal-note allow", got[0])
|
|
}
|
|
}
|
|
|
|
func TestExplainUsesRecordedDecision(t *testing.T) {
|
|
engine := newTestEngine(t)
|
|
|
|
var request api.CheckRequest
|
|
loadYAML(t, filepath.Join("..", "..", "examples", "caring", "check_request.yaml"), &request)
|
|
|
|
decisionEnvelope, err := engine.Check(context.Background(), request)
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
explanation, err := engine.Explain(decisionEnvelope.ID)
|
|
if err != nil {
|
|
t.Fatalf("Explain: %v", err)
|
|
}
|
|
|
|
if explanation.DecisionID != decisionEnvelope.ID {
|
|
t.Fatalf("explanation.DecisionID = %q; want %q", explanation.DecisionID, decisionEnvelope.ID)
|
|
}
|
|
if explanation.Effect != api.DecisionEffectAllow {
|
|
t.Fatalf("explanation.Effect = %q; want allow", explanation.Effect)
|
|
}
|
|
if !strings.Contains(explanation.Summary, "Customer Doer may View Data Plane resource document:internal-note") {
|
|
t.Fatalf("explanation.Summary = %q", explanation.Summary)
|
|
}
|
|
if _, err := engine.Explain("decision:missing"); err == nil {
|
|
t.Fatal("Explain accepted unknown decision id")
|
|
}
|
|
}
|
|
|
|
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"))
|
|
engine.SetDecisionLog(log)
|
|
|
|
got, err := engine.Check(context.Background(), api.CheckRequest{
|
|
ID: "check:logged-deny",
|
|
Subject: api.SubjectRef{ID: "user:alice"},
|
|
Action: "read",
|
|
Resource: api.ResourceRef{
|
|
ID: "document:missing",
|
|
Type: "document",
|
|
System: "markitect-tool",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if got.Effect != api.DecisionEffectDeny {
|
|
t.Fatalf("got.Effect = %q; want deny", got.Effect)
|
|
}
|
|
|
|
decisions, err := log.ReadAll()
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if len(decisions) != 1 {
|
|
t.Fatalf("len(decisions) = %d; want 1", len(decisions))
|
|
}
|
|
if decisions[0].ID != got.ID || decisions[0].Effect != api.DecisionEffectDeny {
|
|
t.Fatalf("logged decision = %+v; want logged deny %s", decisions[0], got.ID)
|
|
}
|
|
}
|
|
|
|
func newTestEngine(t *testing.T) *decision.Engine {
|
|
t.Helper()
|
|
|
|
return newTestEngineWithStore(t, newTestStore(t))
|
|
}
|
|
|
|
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)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func newTestStore(t *testing.T) *registry.Store {
|
|
t.Helper()
|
|
|
|
store, err := registry.LoadFile(filepath.Join("..", "..", "examples", "caring", "registry_snapshot.json"))
|
|
if err != nil {
|
|
t.Fatalf("LoadFile registry: %v", err)
|
|
}
|
|
return store
|
|
}
|
|
|
|
func loadYAML(t *testing.T, path string, out any) {
|
|
t.Helper()
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
if err := yaml.Unmarshal(data, out); err != nil {
|
|
t.Fatalf("unmarshal %s: %v", path, err)
|
|
}
|
|
}
|