Skip to content
7 changes: 6 additions & 1 deletion cmd/gt/verifier_ids_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,17 @@ func gdcReq(action, clientID, b64 string) *authzen.EvaluationRequest {
}

// normalise strips what legitimately differs between two evaluations: timings and
// the policy name that the policy path adds to a positive reason.
// the policy name that the policy path adds to a positive reason, and the
// per-decision id.
func normalise(t *testing.T, resp *authzen.EvaluationResponse) string {
t.Helper()
b, _ := json.Marshal(resp)
var m map[string]interface{}
_ = json.Unmarshal(b, &m)
// The decision id is unique per evaluation.
if c, ok := m["context"].(map[string]interface{}); ok {
delete(c, "id")
}
var scrub func(v interface{})
scrub = func(v interface{}) {
switch x := v.(type) {
Expand Down
20 changes: 20 additions & 0 deletions pkg/authzen/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,26 @@ type EvaluationResponseContext struct {
// when the policy enables it (constraints.return_verifier_ids), the decision
// is true, and the identifier was verified. Informational, never critical.
VerifierIDs []VerifierID `json:"verifier_ids,omitempty"` // Verified verifier identifiers
// TrustData reports how fresh the data behind the decision is, one entry
// per registry consulted. Callers that cache decisions use next_update as
// an upper bound on how long the answer can be kept. Absence of the member,
// or of next_update in an entry, means "unknown": the caller applies its own
// conservative cap.
TrustData []TrustData `json:"trust_data,omitempty"`
}

// TrustData describes the freshness of one registry's data.
// @Description Freshness of the data one registry decided from
type TrustData struct {
// Registry is the registry's configured name.
Registry string `json:"registry" example:"etsi"`
// AsOf is when the registry last loaded its data (RFC 3339, UTC). Omitted
// when the registry has not loaded data or has no such notion.
AsOf string `json:"as_of,omitempty" example:"2026-10-19T00:00:00Z"`
// NextUpdate is the earliest moment the source data is due to be replaced
// (RFC 3339, UTC), for example an ETSI TSL NextUpdate or a VICAL nextUpdate.
// Omitted when the registry has no such notion (static, whitelist).
NextUpdate string `json:"next_update,omitempty" example:"2026-10-26T00:00:00Z"`
}

// Verifier identifier types a PDP can return in EvaluationResponseContext.VerifierIDs.
Expand Down
11 changes: 11 additions & 0 deletions pkg/registry/composite.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ func (c *CompositeRegistry) applyLogic(results []compositeResult) *authzen.Evalu
var agreedRegistries, disagreedRegistries []string
var positive []*authzen.EvaluationResponse
var details []map[string]interface{}
// incomplete is set when a child failed or itself reports an incomplete
// result (nested composites), so callers can tell a denial that can change
// when a registry recovers from one that is final until a list changes.
incomplete := false

for _, r := range results {
info := r.registry.Info()
Expand All @@ -185,6 +189,9 @@ func (c *CompositeRegistry) applyLogic(results []compositeResult) *authzen.Evalu
"duration_ms": r.duration.Milliseconds(),
}

if r.err != nil || r.response == nil || responseIncomplete(r.response) {
incomplete = true
}
if r.err != nil {
errorCount++
detail["error"] = r.err.Error()
Expand Down Expand Up @@ -220,6 +227,10 @@ func (c *CompositeRegistry) applyLogic(results []compositeResult) *authzen.Evalu
"details": details,
}

if incomplete {
reason["incomplete"] = true
}

switch c.operator {
case LogicAND:
decision = agreedCount == totalCount
Expand Down
59 changes: 59 additions & 0 deletions pkg/registry/etsi/freshness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package etsi

import (
"testing"
"time"

"github.com/sirosfoundation/g119612/pkg/etsi119612"
"github.com/stretchr/testify/assert"
)

func tslWithNextUpdate(dt string) *etsi119612.TSL {
tsl := &etsi119612.TSL{}
tsl.StatusList.TslSchemeInformation = &etsi119612.TSLSchemeInformationType{}
if dt != "" {
tsl.StatusList.TslSchemeInformation.TslNextUpdate = &etsi119612.NextUpdateType{DateTime: dt}
}
return tsl
}

func TestFreshness(t *testing.T) {
loaded := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)

r := &TSLRegistry{loadedAt: loaded, tsls: []*etsi119612.TSL{
tslWithNextUpdate("2026-11-01T00:00:00Z"),
tslWithNextUpdate("2026-10-26T00:00:00Z"),
tslWithNextUpdate(""),
nil,
}}
f := r.Freshness()
assert.True(t, f.AsOf.Equal(loaded))
assert.True(t, f.NextUpdate.IsZero(), "one undated TSL makes the registry's NextUpdate unknown")

r = &TSLRegistry{loadedAt: loaded, tsls: []*etsi119612.TSL{
tslWithNextUpdate("2026-11-01T00:00:00Z"),
tslWithNextUpdate("2026-10-26T00:00:00Z"),
nil,
}}
assert.True(t, r.Freshness().NextUpdate.Equal(time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC)),
"the registry is only as fresh as its soonest-expiring TSL")

r = &TSLRegistry{loadedAt: loaded, tsls: []*etsi119612.TSL{tslWithNextUpdate("not a date"), tslWithNextUpdate("2026-10-26T00:00:00Z")}}
assert.True(t, r.Freshness().NextUpdate.IsZero(), "an unparseable date is unknown")

assert.True(t, (&TSLRegistry{loadedAt: loaded}).Freshness().NextUpdate.IsZero(), "no TSLs: no bound")
}

func TestFreshness_UnknownWithPEMBundle(t *testing.T) {
loaded := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
tsls := []*etsi119612.TSL{tslWithNextUpdate("2026-10-26T00:00:00Z")}

r := &TSLRegistry{loadedAt: loaded, tsls: tsls}
assert.False(t, r.Freshness().NextUpdate.IsZero())

// A bundle certificate can authorise a decision without any TSL.
r = &TSLRegistry{loadedAt: loaded, tsls: tsls, config: TSLConfig{CertBundle: "/etc/ca.pem"}}
f := r.Freshness()
assert.True(t, f.NextUpdate.IsZero())
assert.True(t, f.AsOf.Equal(loaded))
}
26 changes: 26 additions & 0 deletions pkg/registry/etsi/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,32 @@ func (r *TSLRegistry) Info() registry.RegistryInfo {
return info
}

// Freshness reports when the TSLs were loaded and the earliest NextUpdate among
// them. A decision may rest on any loaded TSL, so if one of them has no usable
// NextUpdate the registry's NextUpdate is unknown rather than another list's date.
// It is also unknown when a PEM bundle is configured.
func (r *TSLRegistry) Freshness() registry.Freshness {
r.mu.RLock()
defer r.mu.RUnlock()
next := make([]time.Time, 0, len(r.tsls))
for _, tsl := range r.tsls {
if tsl == nil {
continue
}
var t time.Time
if si := tsl.StatusList.TslSchemeInformation; si != nil && si.TslNextUpdate != nil {
t = registry.ParseSourceTime(si.TslNextUpdate.DateTime)
}
next = append(next, t)
}
// Certificates from a configured PEM bundle are trust anchors too, and can
// be what authorises a decision. They have no update bound.
if r.config.CertBundle != "" {
return registry.Freshness{AsOf: r.loadedAt}
}
return registry.Freshness{AsOf: r.loadedAt, NextUpdate: registry.EarliestKnown(next...)}
}
Comment thread
leifj marked this conversation as resolved.
Resolved

// Healthy returns true if the registry has loaded trust data successfully.
func (r *TSLRegistry) Healthy() bool {
r.mu.RLock()
Expand Down
193 changes: 193 additions & 0 deletions pkg/registry/freshness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package registry

import (
"sync"
"time"

"github.com/sirosfoundation/go-trust/pkg/authzen"
)

// Freshness describes how current a registry's data is. A zero field means
// unknown or not applicable.
type Freshness struct {
// AsOf is when the data was last loaded.
AsOf time.Time
// NextUpdate is when the source says it will next be replaced.
NextUpdate time.Time
}

// FreshnessReporter is implemented by registries that know how fresh their data
// is. It is optional: a registry without it is reported with no as_of and no
// next_update, which a caller must read as "unknown".
type FreshnessReporter interface {
Freshness() Freshness
}

// ParseSourceTime parses a timestamp from a trust-data source (ETSI
// dateTime, VICAL/RICAL tdate). The value must carry an explicit UTC offset
// (RFC 3339). An empty, unparseable or offset-free value yields the zero time,
// i.e. "unknown": freshness must never be invented, and assuming a zone for an
// offset-free value could report a deadline later than the source meant.
func ParseSourceTime(s string) time.Time {
t, err := time.Parse(time.RFC3339Nano, s) // also accepts RFC 3339 without fractional seconds
if err != nil {
return time.Time{}
}
return t.UTC()
}

// CacheFreshness reports the freshness of a fetched-and-cached list. view
// returns when the cached list was fetched and the nextUpdate it declares
// (unknown unless it parses, see ParseSourceTime). Nothing is reported before
// the first fetch, nor once the cache has outlived ttl: the next evaluation must
// refetch, and if that fails the registry keeps the old list and denies, a
// denial that can flip as soon as fetching recovers. The expiry test is the
// registries' own (fresh only while time since fetch < ttl), so a zero or
// negative ttl is always expired.
func CacheFreshness[C any](mu *sync.RWMutex, cache **C, ttl time.Duration, view func(*C) (fetchedAt time.Time, nextUpdate string)) Freshness {
mu.RLock()
c := *cache
mu.RUnlock()
if c == nil {
return Freshness{}
}
fetchedAt, nextUpdate := view(c)
if time.Since(fetchedAt) >= ttl {
return Freshness{}
}
return Freshness{AsOf: fetchedAt, NextUpdate: ParseSourceTime(nextUpdate)}
}

// EarliestKnown returns the earliest of the times, or the zero time (unknown)
// when there are none or any of them is unknown. A registry that holds several
// lists can decide from any of them, so it is only as fresh as its
// soonest-expiring list, and unknown if any list is.
func EarliestKnown(times ...time.Time) time.Time {
var earliest time.Time
for _, t := range times {
if t.IsZero() {
return time.Time{}
}
if earliest.IsZero() || t.Before(earliest) {
earliest = t
}
}
return earliest
}

// trustDatum is one registry's freshness at a point in time. The times keep
// full precision so two snapshots are compared exactly, not as the rounded
// strings sent on the wire.
type trustDatum struct {
name string
// group is the name of the top-level registry this one was reached through:
// itself, or the composite containing it.
group string
asOf, next time.Time
}

func (d trustDatum) same(o trustDatum) bool {
return d.name == o.name && d.asOf.Equal(o.asOf) && d.next.Equal(o.next)
}

// collectTrustData appends one entry per leaf registry under reg. A composite
// contributes its children, not itself.
func collectTrustData(reg TrustRegistry, group string, out []trustDatum) []trustDatum {
if c, ok := reg.(*CompositeRegistry); ok {
for _, child := range c.registries {
out = collectTrustData(child, group, out)
}
return out
}
d := trustDatum{name: reg.Info().Name, group: group}
if fr, ok := reg.(FreshnessReporter); ok {
f := fr.Freshness()
d.asOf, d.next = f.AsOf, f.NextUpdate
}
return append(out, d)
}

// trustDataSnapshot returns the current freshness of every registry in regs.
func trustDataSnapshot(regs []TrustRegistry) []trustDatum {
var data []trustDatum
for _, r := range regs {
data = collectTrustData(r, r.Info().Name, data)
}
return data
}

// responseIncomplete reports whether resp says some registry behind it failed
// to answer (see CompositeRegistry).
func responseIncomplete(resp *authzen.EvaluationResponse) bool {
if resp == nil || resp.Context == nil {
return false
}
inc, _ := resp.Context.Reason["incomplete"].(bool)
return inc
}

// failedRegistries names the registries a denial did not really come from: the
// strategy recorded an error for them (including an open circuit breaker or a
// panic) or they gave no decision. Such a denial can become an allow without
// any list changing, so its freshness is unknown. Only a denial is examined: an
// allow stands on the registry that allowed it.
func failedRegistries(resp *authzen.EvaluationResponse) map[string]bool {
if resp == nil || resp.Decision || resp.Context == nil {
return nil
}
failed := map[string]bool{}
for _, key := range []string{"registry_results", "all_results"} {
entries, _ := resp.Context.Reason[key].([]map[string]interface{})
for _, e := range entries {
name, _ := e["registry"].(string)
_, isErr := e["error"]
inner, _ := e["reason"].(map[string]interface{})
nested, _ := inner["incomplete"].(bool) // a composite with a failed child
if decided, ok := e["decision"].(bool); name != "" && (isErr || nested || !ok || decided) {
failed[name] = true
}
}
}
return failed
}

// attachTrustData returns a copy of resp whose context carries the freshness of
// every registry that could have contributed to the decision. The copy matters:
// registries may hand back a response they also keep.
//
// before and after are snapshots taken around the evaluation. A registry may
// reload while it is being evaluated, and its freshness is read outside its own
// lock, so an entry is only reported when both snapshots agree; otherwise the
// data the decision used cannot be told apart from the data now loaded, and the
// entry carries no as_of or next_update (unknown).
//
// The set is the policy-filtered applicable registries, not only those that
// answered first. For a first-match allow that can name a registry that was not
// reached, which only makes the reported bound earlier, never later.
func attachTrustData(resp *authzen.EvaluationResponse, before, after []trustDatum) *authzen.EvaluationResponse {
if resp == nil || len(after) == 0 {
return resp
}
failed := failedRegistries(resp)
data := make([]authzen.TrustData, len(after))
for i, a := range after {
data[i] = authzen.TrustData{Registry: a.name}
if len(before) != len(after) || !before[i].same(a) || failed[a.group] {
continue
}
if !a.asOf.IsZero() {
data[i].AsOf = a.asOf.UTC().Format(time.RFC3339)
}
if !a.next.IsZero() {
data[i].NextUpdate = a.next.UTC().Format(time.RFC3339)
}
}
out := *resp
var ctx authzen.EvaluationResponseContext
if resp.Context != nil {
ctx = *resp.Context
}
ctx.TrustData = data
out.Context = &ctx
return &out
}
Loading
Loading