Finish FLEX-WP-0019 layer-model v0.7 conformance
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:
parent
9689894c15
commit
56940727bf
32 changed files with 1194 additions and 111 deletions
|
|
@ -4,6 +4,9 @@ import (
|
|||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtectedSystemManifest describes a system that delegates authorization to
|
||||
|
|
@ -118,6 +121,10 @@ type PolicyPackageMetadata struct {
|
|||
Caring CaringPolicyMetadata `json:"caring" yaml:"caring"`
|
||||
Activation map[string]any `json:"activation,omitempty" yaml:"activation,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
// AllowTTL is a Go duration (for example "15m") that bounds every allow
|
||||
// this package produces. Omit to use DefaultAllowTTL. "none" or "0s"
|
||||
// means no stated end; the engine denies those allows (§9.7.1).
|
||||
AllowTTL string `json:"allow_ttl,omitempty" yaml:"allow_ttl,omitempty"`
|
||||
}
|
||||
|
||||
// CaringPolicyMetadata declares the CARING envelope a policy governs.
|
||||
|
|
@ -202,10 +209,14 @@ const (
|
|||
DecisionEffectNotApplicable DecisionEffect = "not_applicable"
|
||||
)
|
||||
|
||||
// DecisionRecordContractV1 is the published decision-record contract identifier.
|
||||
const DecisionRecordContractV1 = "flex-auth.decision-record.v1"
|
||||
|
||||
// DecisionEnvelope is the stable response produced by standalone and delegated
|
||||
// evaluators.
|
||||
// evaluators. It is flex-auth's published decision-record contract (§17).
|
||||
type DecisionEnvelope struct {
|
||||
ID string `json:"id" yaml:"id"`
|
||||
ContractVersion string `json:"contract_version,omitempty" yaml:"contract_version,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty" yaml:"request_id,omitempty"`
|
||||
Effect DecisionEffect `json:"effect" yaml:"effect"`
|
||||
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
|
||||
|
|
@ -214,12 +225,35 @@ type DecisionEnvelope struct {
|
|||
Resource ResourceRef `json:"resource" yaml:"resource"`
|
||||
Subject SubjectRef `json:"subject" yaml:"subject"`
|
||||
Binding *DecisionBinding `json:"binding,omitempty" yaml:"binding,omitempty"`
|
||||
Lifetime *DecisionLifetime `json:"lifetime,omitempty" yaml:"lifetime,omitempty"`
|
||||
Obligations []Obligation `json:"obligations,omitempty" yaml:"obligations,omitempty"`
|
||||
Diagnostics map[string]any `json:"diagnostics,omitempty" yaml:"diagnostics,omitempty"`
|
||||
Provenance DecisionProvenance `json:"provenance" yaml:"provenance"`
|
||||
Caring *CaringDecisionMetadata `json:"caring,omitempty" yaml:"caring,omitempty"`
|
||||
}
|
||||
|
||||
// DecisionLifetimeKind identifies how an allow ends.
|
||||
type DecisionLifetimeKind string
|
||||
|
||||
const (
|
||||
DecisionLifetimeTTL DecisionLifetimeKind = "ttl"
|
||||
)
|
||||
|
||||
// DefaultAllowTTL is the engine default when a policy package omits allow_ttl.
|
||||
const DefaultAllowTTL = 15 * time.Minute
|
||||
|
||||
// ReasonAllowLifetimeUnstated is the deny reason for an allow with no stated end.
|
||||
const ReasonAllowLifetimeUnstated = "allow_lifetime_unstated"
|
||||
|
||||
// DecisionLifetime bounds an allow (§9.7.1). flex-auth has no session concept,
|
||||
// so the first honest shape is a policy-package-declared TTL.
|
||||
type DecisionLifetime struct {
|
||||
Kind DecisionLifetimeKind `json:"kind" yaml:"kind"`
|
||||
TTL string `json:"ttl,omitempty" yaml:"ttl,omitempty"`
|
||||
NotBefore string `json:"not_before,omitempty" yaml:"not_before,omitempty"`
|
||||
ExpiresAt string `json:"expires_at" yaml:"expires_at"`
|
||||
}
|
||||
|
||||
// DecisionBinding is the exact normalized authorization request evaluated by
|
||||
// a decision. It lets a consumer verify structured action, target, actor, and
|
||||
// context fields without parsing reason or diagnostic prose.
|
||||
|
|
@ -232,11 +266,20 @@ type DecisionBinding struct {
|
|||
RequestDigest string `json:"request_digest" yaml:"request_digest"`
|
||||
}
|
||||
|
||||
// requestDigestMaterial is the exact tuple hashed for §6.4.2 replay. Request
|
||||
// id, policy version, and caring_context are excluded: id is correlation, the
|
||||
// version is provenance, and caring_context is an input-claim digest.
|
||||
type requestDigestMaterial struct {
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
Subject SubjectRef `json:"subject"`
|
||||
Action string `json:"action"`
|
||||
Resource ResourceRef `json:"resource"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// NewDecisionBinding returns a stable structured binding for the exact request
|
||||
// an evaluator consumed.
|
||||
func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
||||
data, _ := json.Marshal(request)
|
||||
sum := sha256.Sum256(data)
|
||||
contextCopy := make(map[string]any, len(request.Context))
|
||||
for key, value := range request.Context {
|
||||
contextCopy[key] = value
|
||||
|
|
@ -247,7 +290,131 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding {
|
|||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: contextCopy,
|
||||
RequestDigest: "sha256:" + hex.EncodeToString(sum[:]),
|
||||
RequestDigest: RequestDigest(request),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
|
||||
// JSON of tenant, subject, action, resource, and context.
|
||||
func RequestDigest(request CheckRequest) string {
|
||||
return CanonicalDigest(requestDigestMaterial{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: request.Action,
|
||||
Resource: request.Resource,
|
||||
Context: request.Context,
|
||||
})
|
||||
}
|
||||
|
||||
// CanonicalDigest returns "sha256:" plus the hex SHA-256 of canonical JSON.
|
||||
// encoding/json sorts map keys, so two equal Go values agree.
|
||||
func CanonicalDigest(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
sum := sha256.Sum256(nil)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// InputClaimDigests hashes the request-time claim classes the evaluator joined.
|
||||
func InputClaimDigests(request CheckRequest) map[string]string {
|
||||
digests := make(map[string]string)
|
||||
if len(request.Context) > 0 {
|
||||
digests["context"] = CanonicalDigest(request.Context)
|
||||
}
|
||||
if request.CaringContext != nil {
|
||||
digests["caring_context"] = CanonicalDigest(request.CaringContext)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
return digests
|
||||
}
|
||||
|
||||
// DecisionCompletion carries evaluator-side inputs used to finish an envelope.
|
||||
type DecisionCompletion struct {
|
||||
AllowTTL string
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CompleteDecision stamps contract version, input-claim digests, decision time,
|
||||
// and an explicit allow lifetime. An allow with no stated end becomes a deny.
|
||||
func CompleteDecision(envelope *DecisionEnvelope, request CheckRequest, completion DecisionCompletion) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if envelope.ContractVersion == "" {
|
||||
envelope.ContractVersion = DecisionRecordContractV1
|
||||
}
|
||||
if envelope.Provenance.InputClaimDigests == nil {
|
||||
envelope.Provenance.InputClaimDigests = InputClaimDigests(request)
|
||||
}
|
||||
ApplyAllowLifetime(envelope, completion.AllowTTL, completion.Now)
|
||||
}
|
||||
|
||||
// ParseAllowTTL resolves a package-declared TTL. ok is false when the allow
|
||||
// would have no stated end. Invalid strings return an error so package
|
||||
// validation can reject them.
|
||||
func ParseAllowTTL(declared string) (time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(declared)
|
||||
if trimmed == "" {
|
||||
return DefaultAllowTTL, nil
|
||||
}
|
||||
if strings.EqualFold(trimmed, "none") {
|
||||
return 0, nil
|
||||
}
|
||||
ttl, err := time.ParseDuration(trimmed)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("allow_ttl %q is not a Go duration: %w", declared, err)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return ttl, nil
|
||||
}
|
||||
|
||||
// ApplyAllowLifetime sets DecisionTime and, for allows, an explicit TTL. A
|
||||
// missing or zero TTL denies the allow rather than mint a standing grant.
|
||||
func ApplyAllowLifetime(envelope *DecisionEnvelope, declaredTTL string, now time.Time) {
|
||||
if envelope == nil {
|
||||
return
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now().UTC()
|
||||
} else {
|
||||
now = now.UTC()
|
||||
}
|
||||
if envelope.Provenance.DecisionTime == "" {
|
||||
envelope.Provenance.DecisionTime = now.Format(time.RFC3339)
|
||||
}
|
||||
if envelope.Effect != DecisionEffectAllow {
|
||||
return
|
||||
}
|
||||
ttl, err := ParseAllowTTL(declaredTTL)
|
||||
if err != nil || ttl <= 0 {
|
||||
if envelope.Diagnostics == nil {
|
||||
envelope.Diagnostics = map[string]any{}
|
||||
}
|
||||
if envelope.Reason != "" {
|
||||
envelope.Diagnostics["unstated_allow_reason"] = envelope.Reason
|
||||
}
|
||||
envelope.Effect = DecisionEffectDeny
|
||||
envelope.Reason = ReasonAllowLifetimeUnstated
|
||||
envelope.MatchedRule = ReasonAllowLifetimeUnstated
|
||||
envelope.Lifetime = nil
|
||||
return
|
||||
}
|
||||
display := strings.TrimSpace(declaredTTL)
|
||||
if display == "" {
|
||||
display = "15m"
|
||||
}
|
||||
envelope.Lifetime = &DecisionLifetime{
|
||||
Kind: DecisionLifetimeTTL,
|
||||
TTL: display,
|
||||
NotBefore: now.Format(time.RFC3339),
|
||||
ExpiresAt: now.Add(ttl).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,12 +473,15 @@ type Obligation struct {
|
|||
|
||||
// DecisionProvenance captures evaluator and policy provenance.
|
||||
type DecisionProvenance struct {
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
Evaluator string `json:"evaluator" yaml:"evaluator"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
PolicyPackage string `json:"policy_package,omitempty" yaml:"policy_package,omitempty"`
|
||||
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty" yaml:"policy_package_digest,omitempty"`
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest,omitempty" yaml:"registry_snapshot_digest,omitempty"`
|
||||
DirectoryETag string `json:"directory_etag,omitempty" yaml:"directory_etag,omitempty"`
|
||||
InputClaimDigests map[string]string `json:"input_claim_digests,omitempty" yaml:"input_claim_digests,omitempty"`
|
||||
DecisionTime string `json:"decision_time,omitempty" yaml:"decision_time,omitempty"`
|
||||
}
|
||||
|
||||
// CaringDecisionMetadata carries CARING descriptor and conformance details in
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
|
|
@ -71,6 +73,12 @@ func TestDecisionAndAuditExamplesParse(t *testing.T) {
|
|||
if decision.Effect != api.DecisionEffectAllow {
|
||||
t.Errorf("Decision.Effect = %q; want allow", decision.Effect)
|
||||
}
|
||||
if decision.Lifetime == nil || decision.Lifetime.Kind != api.DecisionLifetimeTTL {
|
||||
t.Fatalf("Decision.Lifetime = %+v; want ttl", decision.Lifetime)
|
||||
}
|
||||
if decision.ContractVersion != api.DecisionRecordContractV1 {
|
||||
t.Errorf("Decision.ContractVersion = %q", decision.ContractVersion)
|
||||
}
|
||||
if decision.Caring == nil || decision.Caring.Profile != api.CaringProfileCaring040RC2 {
|
||||
t.Fatalf("Decision.Caring = %+v; want CARING profile metadata", decision.Caring)
|
||||
}
|
||||
|
|
@ -112,6 +120,80 @@ func TestActionAuthorizationExampleParses(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRequestDigestIgnoresIDAndChangesWithAction(t *testing.T) {
|
||||
request := api.CheckRequest{
|
||||
ID: "check:one",
|
||||
Tenant: "tenant:alpha",
|
||||
Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman},
|
||||
Action: "read",
|
||||
Resource: api.ResourceRef{
|
||||
ID: "document:internal-note",
|
||||
Type: "document",
|
||||
System: "markitect-tool",
|
||||
},
|
||||
Context: map[string]any{"purpose": "project-delivery"},
|
||||
}
|
||||
first := api.RequestDigest(request)
|
||||
if !strings.HasPrefix(first, "sha256:") || len(first) != len("sha256:")+64 {
|
||||
t.Fatalf("RequestDigest = %q", first)
|
||||
}
|
||||
|
||||
same := request
|
||||
same.ID = "check:other"
|
||||
same.PolicyVersion = "v9"
|
||||
if api.RequestDigest(same) != first {
|
||||
t.Fatal("digest changed when only id/policy_version changed")
|
||||
}
|
||||
|
||||
changed := request
|
||||
changed.Action = "destroy"
|
||||
if api.RequestDigest(changed) == first {
|
||||
t.Fatal("digest did not change when action changed")
|
||||
}
|
||||
|
||||
binding := api.NewDecisionBinding(request)
|
||||
if binding.RequestDigest != first {
|
||||
t.Fatalf("binding digest %q != RequestDigest %q", binding.RequestDigest, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAllowLifetimeDefaultDeclaredAndNone(t *testing.T) {
|
||||
now := mustParseTime(t, "2026-08-29T12:00:00Z")
|
||||
|
||||
allow := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
|
||||
api.ApplyAllowLifetime(&allow, "", now)
|
||||
if allow.Effect != api.DecisionEffectAllow || allow.Lifetime == nil {
|
||||
t.Fatalf("default TTL denied or skipped: %+v", allow)
|
||||
}
|
||||
if allow.Lifetime.TTL != "15m" || allow.Lifetime.ExpiresAt != "2026-08-29T12:15:00Z" {
|
||||
t.Fatalf("default lifetime = %+v", allow.Lifetime)
|
||||
}
|
||||
|
||||
declared := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
|
||||
api.ApplyAllowLifetime(&declared, "5m", now)
|
||||
if declared.Lifetime == nil || declared.Lifetime.TTL != "5m" || declared.Lifetime.ExpiresAt != "2026-08-29T12:05:00Z" {
|
||||
t.Fatalf("declared lifetime = %+v", declared.Lifetime)
|
||||
}
|
||||
|
||||
unstated := api.DecisionEnvelope{Effect: api.DecisionEffectAllow, Reason: "reader_relation"}
|
||||
api.ApplyAllowLifetime(&unstated, "none", now)
|
||||
if unstated.Effect != api.DecisionEffectDeny || unstated.Reason != api.ReasonAllowLifetimeUnstated {
|
||||
t.Fatalf("unstated allow = %+v; want deny", unstated)
|
||||
}
|
||||
if unstated.Lifetime != nil {
|
||||
t.Fatalf("unstated allow still has lifetime %+v", unstated.Lifetime)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T, value string) time.Time {
|
||||
t.Helper()
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time %q: %v", value, err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestSchemaFilesAreJSON(t *testing.T) {
|
||||
schemaDir := filepath.Join("..", "..", "schemas")
|
||||
entries, err := os.ReadDir(schemaDir)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue