// 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/access-engine/internal/decision" "github.com/netkingdom/access-engine/internal/policy" "github.com/netkingdom/access-engine/internal/registry" "github.com/netkingdom/access-engine/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 no effect is released without its required record (GH-DEC-2026-024). 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 }