Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
199 lines
7.5 KiB
Go
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 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
|
|
}
|