access-engine/internal/readback/readback.go
tegwick f758381cea
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 6s
Build and Publish Container Image / build-and-push (push) Successful in 1m23s
FLEX-WP-0036: Go module is github.com/netkingdom/access-engine; T02 consumer inventory
- module path renamed (no external importers); golangci local-prefixes
- ACCESS_ENGINE_RUN_TOPAZ_INTEGRATION replaces FLEX_AUTH_RUN_TOPAZ_INTEGRATION
- README title and identity paragraph reflect the landed renames
- docs/runtime-rename-inventory.md: per-consumer references by kind and order
- chart name, image package and /opt paths deliberately left for T05

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 735967@bnt-lap001
Assistant-Session: 5971967f-77f8-4ced-9e02-c6ef67040ffc
2026-10-10 15:29:00 +02:00

199 lines
7.5 KiB
Go

// 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 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
}