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
45 changes: 45 additions & 0 deletions pkg/registry/etsi/freshness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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")
}
20 changes: 20 additions & 0 deletions pkg/registry/etsi/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,26 @@ 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.
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)
}
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
120 changes: 120 additions & 0 deletions pkg/registry/freshness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package registry

import (
"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). An empty or unparseable value yields the zero
// time, i.e. "unknown": freshness must never be invented.
func ParseSourceTime(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}

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

// collectTrustData appends one TrustData entry per leaf registry under reg.
// A composite contributes its children, not itself.
func collectTrustData(reg TrustRegistry, out []authzen.TrustData) []authzen.TrustData {
if c, ok := reg.(*CompositeRegistry); ok {
for _, child := range c.registries {
out = collectTrustData(child, out)
}
return out
}
td := authzen.TrustData{Registry: reg.Info().Name}
if fr, ok := reg.(FreshnessReporter); ok {
f := fr.Freshness()
if !f.AsOf.IsZero() {
td.AsOf = f.AsOf.UTC().Format(time.RFC3339)
}
if !f.NextUpdate.IsZero() {
td.NextUpdate = f.NextUpdate.UTC().Format(time.RFC3339)
}
}
return append(out, td)
}

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

// 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 []authzen.TrustData) *authzen.EvaluationResponse {
if resp == nil || len(after) == 0 {
return resp
}
data := make([]authzen.TrustData, len(after))
for i, a := range after {
data[i] = a
if len(before) != len(after) || before[i] != a {
data[i] = authzen.TrustData{Registry: a.Registry}
}
}
out := *resp
var ctx authzen.EvaluationResponseContext
if resp.Context != nil {
ctx = *resp.Context
}
ctx.TrustData = data
out.Context = &ctx
return &out
}
Loading
Loading