FLEX-WP-0035 T01-T03: application access readback pin
All checks were successful
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) Successful in 58s

Contract accepted by user-engine; FLEX-DEC-2026-019 accepted.
- serve-readback: dedicated pin, no enforcement routes, own caller binding
- CheckRequest.purpose hashed into every digest when set; /v1/check refuses it
- POST /v1/readback/application_access and GET /v1/readback/systems
- optional client_binding on protected systems; readback schemas
- schemaguard implements maxItems
- T05 added: informed-decision needs an application.access rule over
  realm-level claims (its lifecycle rules read authentication_audience)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 609675@bnt-lap001
Assistant-Session: 4987a36e-eb38-4b10-803c-15a29cd78212
This commit is contained in:
tegwick 2026-10-09 20:02:31 +02:00
parent b6d6bff85e
commit 8f2ba75e4a
23 changed files with 1314 additions and 44 deletions

View file

@ -15,12 +15,21 @@ type ProtectedSystemManifest struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name,omitempty" yaml:"name,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
ClientBinding *ClientBinding `json:"client_binding,omitempty" yaml:"client_binding,omitempty"`
ResourceTypes []ResourceType `json:"resource_types,omitempty" yaml:"resource_types,omitempty"`
Actions []ActionDefinition `json:"actions,omitempty" yaml:"actions,omitempty"`
CaringProfiles []string `json:"caring_profiles,omitempty" yaml:"caring_profiles,omitempty"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
}
// ClientBinding names the key-cape client a protected system's users sign in
// through. It is descriptive only: the readback registry projection lists it,
// and it never decides an allow (FLEX-DEC-2026-019).
type ClientBinding struct {
Issuer string `json:"issuer" yaml:"issuer"`
ClientID string `json:"client_id" yaml:"client_id"`
}
// ResourceType describes a resource namespace entry owned by a protected system.
type ResourceType struct {
Name string `json:"name" yaml:"name"`
@ -168,8 +177,18 @@ type CheckRequest struct {
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
CaringContext *CaringAccessDescriptor `json:"caring_context,omitempty" yaml:"caring_context,omitempty"`
PolicyVersion string `json:"policy_version,omitempty" yaml:"policy_version,omitempty"`
// Purpose is empty on every enforcement request. The readback pin sets it
// to PurposeReadback, and it is hashed into every request digest, so an
// advisory readback decision can never pass a PEP's §6.4.2 replay test for
// an enforcement request (FLEX-DEC-2026-019). /v1/check and /v1/batch_check
// refuse a request that carries it.
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
}
// PurposeReadback marks an advisory application-access readback decision.
const PurposeReadback = "readback"
// BatchCheckRequest evaluates one subject/action against multiple resources.
type BatchCheckRequest struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
@ -305,12 +324,15 @@ type DecisionLifetime struct {
// a decision. It lets a consumer verify structured action, target, actor, and
// context fields without parsing reason or diagnostic prose.
type DecisionBinding struct {
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"`
Subject SubjectRef `json:"subject" yaml:"subject"`
Action string `json:"action" yaml:"action"`
Resource ResourceRef `json:"resource" yaml:"resource"`
Context map[string]any `json:"context,omitempty" yaml:"context,omitempty"`
// Purpose is "readback" on an advisory readback decision and absent on an
// enforcement decision (FLEX-DEC-2026-019).
Purpose string `json:"purpose,omitempty" yaml:"purpose,omitempty"`
RequestDigest string `json:"request_digest" yaml:"request_digest"`
// ApprovalBindingDigest is RequestDigest computed with the approval claim
// removed from context. It exists to break a circularity: an approval's
@ -358,6 +380,7 @@ type requestDigestMaterial struct {
Action string `json:"action"`
Resource ResourceRef `json:"resource"`
Context map[string]any `json:"context,omitempty"`
Purpose string `json:"purpose,omitempty"`
}
// NewDecisionBinding returns a stable structured binding for the exact request
@ -377,6 +400,7 @@ func NewDecisionBinding(request CheckRequest) *DecisionBinding {
Action: request.Action,
Resource: request.Resource,
Context: contextCopy,
Purpose: request.Purpose,
RequestDigest: RequestDigest(request),
}
if _, carries := request.Context[ApprovalContextKey]; carries {
@ -427,11 +451,13 @@ func ApprovalBindingDigest(request CheckRequest) string {
Action: request.Action,
Resource: request.Resource,
Context: stripped,
Purpose: request.Purpose,
})
}
// RequestDigest is the mechanical §6.4.2 replay test: SHA-256 over canonical
// JSON of tenant, subject, action, resource, and context.
// JSON of tenant, subject, action, resource, context, and purpose. Purpose is
// omitted when empty, so enforcement digests are unchanged by its existence.
func RequestDigest(request CheckRequest) string {
return CanonicalDigest(requestDigestMaterial{
Tenant: request.Tenant,
@ -439,6 +465,7 @@ func RequestDigest(request CheckRequest) string {
Action: request.Action,
Resource: request.Resource,
Context: request.Context,
Purpose: request.Purpose,
})
}

25
pkg/api/purpose_test.go Normal file
View file

@ -0,0 +1,25 @@
package api
import "testing"
// FLEX-DEC-2026-019: purpose is hashed when set and absent when empty, so an
// enforcement digest is unchanged and a readback digest can never equal one.
func TestPurposeEntersDigestsOnlyWhenSet(t *testing.T) {
request := CheckRequest{
Subject: SubjectRef{ID: "user:alice"},
Action: "application.access",
Resource: ResourceRef{ID: "demo-app", System: "demo-app"},
}
legacy := CanonicalDigest(requestDigestMaterial{Subject: request.Subject, Action: request.Action, Resource: request.Resource})
if RequestDigest(request) != legacy {
t.Fatal("an empty purpose changed the enforcement digest")
}
readback := request
readback.Purpose = PurposeReadback
if RequestDigest(readback) == RequestDigest(request) || ApprovalBindingDigest(readback) == ApprovalBindingDigest(request) {
t.Fatal("purpose did not enter the digests")
}
if binding := NewDecisionBinding(readback); binding.Purpose != PurposeReadback {
t.Fatalf("binding purpose = %q", binding.Purpose)
}
}