FLEX-WP-0035 T01-T03: application access readback pin
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:
parent
b6d6bff85e
commit
8f2ba75e4a
23 changed files with 1314 additions and 44 deletions
199
internal/readback/readback.go
Normal file
199
internal/readback/readback.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
// Package readback answers which applications a subject may begin to use.
|
||||
//
|
||||
// A readback decision is advisory. It is evaluated by each participating
|
||||
// system's own versioned package, recorded like any decision, and carries
|
||||
// purpose "readback" inside every request digest, so no PEP can accept it as an
|
||||
// enforcement decision. Only the action ApplicationAccess is ever evaluated
|
||||
// (FLEX-DEC-2026-019, docs/application-access-readback.md).
|
||||
package readback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
decisioncore "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"
|
||||
)
|
||||
|
||||
// ApplicationAccess is the only action the readback evaluates. A package takes
|
||||
// part by declaring it; the rules are the package's own.
|
||||
const ApplicationAccess = "application.access"
|
||||
|
||||
// ResourceType is the resource type of the application a readback names. The
|
||||
// resource id is the protected-system id.
|
||||
const ResourceType = "application"
|
||||
|
||||
// MaxSystems bounds one request (user-engine review, 2026-10-09).
|
||||
const MaxSystems = 64
|
||||
|
||||
// ReasonNotOffered is the reason on a not_applicable row for a system the pin
|
||||
// does not know or whose package does not declare ApplicationAccess.
|
||||
const ReasonNotOffered = "application_access_not_declared"
|
||||
|
||||
// RevocationVisibility restates the per-input-class deadlines published in
|
||||
// docs/decision-input-freshness.md for the classes the readback pin joins. It
|
||||
// commits to nothing beyond them. The pin is standalone, so no directory
|
||||
// class is joined.
|
||||
var RevocationVisibility = map[string]string{
|
||||
"claims": "immediate",
|
||||
"registry": "reload_or_lifetime",
|
||||
"policy_package": "reload_or_lifetime",
|
||||
}
|
||||
|
||||
// ErrInvalidRequest marks a request refused before evaluation.
|
||||
var ErrInvalidRequest = errors.New("invalid readback request")
|
||||
|
||||
// Request names a subject, the claims user-engine verified for it, and the
|
||||
// systems to read back. Subject and Tenant have the /v1/check shape.
|
||||
type Request struct {
|
||||
Tenant string `json:"tenant,omitempty"`
|
||||
Subject api.SubjectRef `json:"subject"`
|
||||
Context map[string]any `json:"context,omitempty"`
|
||||
Systems []string `json:"systems"`
|
||||
}
|
||||
|
||||
// Result is one row, in request order. A system is never omitted.
|
||||
type Result struct {
|
||||
System string `json:"system"`
|
||||
Effect api.DecisionEffect `json:"effect"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
DecisionID string `json:"decision_id,omitempty"`
|
||||
Lifetime *api.DecisionLifetime `json:"lifetime,omitempty"`
|
||||
RevocationVisibility map[string]string `json:"revocation_visibility,omitempty"`
|
||||
PolicyPackage string `json:"policy_package,omitempty"`
|
||||
PolicyPackageDigest string `json:"policy_package_digest,omitempty"`
|
||||
}
|
||||
|
||||
// Response is the whole readback answer.
|
||||
type Response struct {
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest"`
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
// System is one row of the registry projection.
|
||||
type System struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ClientBinding *api.ClientBinding `json:"client_binding,omitempty"`
|
||||
Readback bool `json:"readback"`
|
||||
}
|
||||
|
||||
// Systems is the read-only registry projection.
|
||||
type Systems struct {
|
||||
RegistrySnapshotDigest string `json:"registry_snapshot_digest"`
|
||||
Systems []System `json:"systems"`
|
||||
}
|
||||
|
||||
// Service evaluates readbacks over one merged registry and one package per
|
||||
// participating system.
|
||||
type Service struct {
|
||||
store *registry.Store
|
||||
engines map[string]*decisioncore.Engine
|
||||
declared map[string]bool
|
||||
}
|
||||
|
||||
// New builds one engine per system over the shared store. Every system named
|
||||
// in packages must exist in the store.
|
||||
func New(store *registry.Store, packages map[string]*policy.Package) (*Service, error) {
|
||||
known := map[string]bool{}
|
||||
for _, system := range store.Snapshot().Systems {
|
||||
known[system.ID] = true
|
||||
}
|
||||
engines := make(map[string]*decisioncore.Engine, len(packages))
|
||||
declared := make(map[string]bool, len(packages))
|
||||
for system, pkg := range packages {
|
||||
if !known[system] {
|
||||
return nil, fmt.Errorf("readback package for %q names a system absent from the registry", system)
|
||||
}
|
||||
engine, err := decisioncore.NewEngine(store, pkg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("readback package for %q: %w", system, err)
|
||||
}
|
||||
engines[system] = engine
|
||||
declared[system] = slices.Contains(pkg.Metadata.Actions, ApplicationAccess)
|
||||
}
|
||||
return &Service{store: store, engines: engines, declared: declared}, nil
|
||||
}
|
||||
|
||||
// SetDecisionLog records every engine's decisions to log.
|
||||
func (s *Service) SetDecisionLog(log decisioncore.DecisionRecorder) {
|
||||
for _, engine := range s.engines {
|
||||
engine.SetDecisionLog(log)
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate answers one row per requested system. Any evaluation or record
|
||||
// error fails the whole request: the consumer renders every row unavailable,
|
||||
// and a withheld allow (FLEX-DEC-2026-018) is never released.
|
||||
func (s *Service) Evaluate(ctx context.Context, request Request) (Response, error) {
|
||||
if err := validate(request); err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
response := Response{RegistrySnapshotDigest: s.store.Digest(), Results: make([]Result, 0, len(request.Systems))}
|
||||
for _, system := range request.Systems {
|
||||
engine, ok := s.engines[system]
|
||||
if !ok || !s.declared[system] {
|
||||
response.Results = append(response.Results, Result{System: system, Effect: api.DecisionEffectNotApplicable, Reason: ReasonNotOffered})
|
||||
continue
|
||||
}
|
||||
decision, err := engine.Check(ctx, api.CheckRequest{
|
||||
Tenant: request.Tenant,
|
||||
Subject: request.Subject,
|
||||
Action: ApplicationAccess,
|
||||
Resource: api.ResourceRef{ID: system, Type: ResourceType, System: system},
|
||||
Context: request.Context,
|
||||
Purpose: api.PurposeReadback,
|
||||
})
|
||||
if err != nil {
|
||||
return Response{}, fmt.Errorf("readback %q: %w", system, err)
|
||||
}
|
||||
response.Results = append(response.Results, Result{
|
||||
System: system,
|
||||
Effect: decision.Effect,
|
||||
Reason: decision.Reason,
|
||||
DecisionID: decision.ID,
|
||||
Lifetime: decision.Lifetime,
|
||||
RevocationVisibility: RevocationVisibility,
|
||||
PolicyPackage: decision.Provenance.PolicyPackage,
|
||||
PolicyPackageDigest: decision.Provenance.PolicyPackageDigest,
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// Systems projects the merged registry. It authorizes nothing.
|
||||
func (s *Service) Systems() Systems {
|
||||
snapshot := s.store.Snapshot()
|
||||
out := Systems{RegistrySnapshotDigest: s.store.Digest(), Systems: make([]System, 0, len(snapshot.Systems))}
|
||||
for _, system := range snapshot.Systems {
|
||||
out.Systems = append(out.Systems, System{
|
||||
ID: system.ID,
|
||||
Name: system.Name,
|
||||
ClientBinding: system.ClientBinding,
|
||||
Readback: s.declared[system.ID],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validate(request Request) error {
|
||||
if strings.TrimSpace(request.Subject.ID) == "" {
|
||||
return fmt.Errorf("%w: subject.id is required", ErrInvalidRequest)
|
||||
}
|
||||
if len(request.Systems) == 0 || len(request.Systems) > MaxSystems {
|
||||
return fmt.Errorf("%w: systems must name 1 to %d systems", ErrInvalidRequest, MaxSystems)
|
||||
}
|
||||
seen := make(map[string]bool, len(request.Systems))
|
||||
for _, system := range request.Systems {
|
||||
if strings.TrimSpace(system) == "" || seen[system] {
|
||||
return fmt.Errorf("%w: systems must be non-empty and unique", ErrInvalidRequest)
|
||||
}
|
||||
seen[system] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue