Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 4 additions & 9 deletions internal/autoconfig/stream_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,16 +498,11 @@ func (s *StreamManager) dispatchEnvAction(id config.EnvironmentID, rep envfactor
}

// validateCredentialPayload checks that an environment rep carries a structurally valid credential
// set. It is run at the stream parse boundary — before the rep's version is recorded via Upsert —
// mirroring how an unparseable event is handled by gotMalformedEvent.
//
// A malformed credential payload must preserve the previous accepted set and force a
// stream reconnect (RAC is one-way push with no NAK channel, so the reconnect is what makes the
// backend resend a fresh put). Validating here rather than after Upsert is essential: the version is
// not advanced, so the fresh put — which carries the same version — is not deduplicated away by the
// MessageReceiver. Any error from BuildAcceptedSet is a *MalformedCredentialSetError.
// set. It runs at the stream parse boundary, before the rep's version is recorded via Upsert: a
// malformed payload must not advance the version, or the backend's fresh put — which carries the same
// version — would be deduplicated away by the MessageReceiver.
func (s *StreamManager) validateCredentialPayload(rep envfactory.EnvironmentRep) error {
_, err := envfactory.BuildAcceptedSet(rep.ToParams())
_, _, err := envfactory.BuildAcceptedSet(rep.ToParams())
return err
}
Comment thread
aaron-zeisler marked this conversation as resolved.

Expand Down
16 changes: 10 additions & 6 deletions internal/credential/accepted_set.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package credential

import (
"errors"
"fmt"

"github.com/launchdarkly/ld-relay/v8/config"
Expand Down Expand Up @@ -48,11 +47,6 @@ func (s AcceptedSet) hasMobileKey(key config.MobileKey) bool {
return ok
}

// errAcceptedSetMissingSDKKey is returned by AcceptedSetBuilder.Build when no SDK key was added. An
// environment must always have at least one SDK key (its anchor), so an empty set indicates a caller
// mistake rather than a benign edge case — surfacing it avoids a silent misconfiguration.
var errAcceptedSetMissingSDKKey = errors.New("accepted credential set must contain at least one SDK key")

// MalformedCredentialSetError is returned when a credential payload cannot produce a valid
// AcceptedSet. This covers:
//
Expand All @@ -65,6 +59,9 @@ var errAcceptedSetMissingSDKKey = errors.New("accepted credential set must conta
// would keep using the previous (possibly revoked) primary. (No mobile keys at all is valid.)
// 4. An entry in sdkKeys[] or mobileKeys[] has an empty value — a credential that would be
// accepted by relay but can never authenticate any SDK.
// 5. No SDK key survived at all, so the environment would have nothing to authenticate with. A
// payload reaches this by combining an undefined anchor with an sdkKeys[] array that is either
// empty or entirely made up of keys relay excludes, such as keys scoped to a view.
//
// Validation happens before Reconcile is called; Rotator.Reconcile trusts the set it is handed.
// Because the error is raised before any state mutation, the environment's previous accepted set is
Expand All @@ -86,6 +83,13 @@ func newMissingAnchorError() *MalformedCredentialSetError {
return &MalformedCredentialSetError{msg: "malformed credential set: anchor SDK key is missing"}
}

// newNoSDKKeysError returns a MalformedCredentialSetError for a set that ended up with no SDK key at
// all. The message describes the payload rather than the builder, because that is what an operator
// reading the log can act on.
func newNoSDKKeysError() *MalformedCredentialSetError {
return &MalformedCredentialSetError{msg: "malformed credential set: no usable SDK key in sdkKeys[]"}
}

// NewAnchorNotInSetError returns a MalformedCredentialSetError for a payload whose designated anchor
// (sdkKey.value) is defined but not present in the sdkKeys[] array — a structural inconsistency. The
// anchor value is a secret, so it is deliberately not included in the message.
Expand Down
6 changes: 3 additions & 3 deletions internal/credential/accepted_set_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,13 @@ func (b *AcceptedSetBuilder) WithEnvironmentID(id config.EnvironmentID) *Accepte
return b
}

// Build validates and returns the accumulated AcceptedSet. It returns errAcceptedSetMissingSDKKey if
// no SDK key was added, or a *MalformedCredentialSetError if no anchor was designated (via
// Build validates and returns the accumulated AcceptedSet. It returns a
// *MalformedCredentialSetError if no SDK key was added, or if no anchor was designated (via
// WithAnchor). Because WithAnchor also adds the key, a designated anchor is always among the
// accepted SDK keys.
func (b *AcceptedSetBuilder) Build() (AcceptedSet, error) {
if len(b.set.sdkKeys) == 0 {
return AcceptedSet{}, errAcceptedSetMissingSDKKey
return AcceptedSet{}, newNoSDKKeysError()
}
if !b.set.anchor.Defined() {
return AcceptedSet{}, newMissingAnchorError()
Expand Down
6 changes: 3 additions & 3 deletions internal/credential/accepted_set_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import (
)

func TestAcceptedSetBuilderValidation(t *testing.T) {
// No SDK key at all is a caller error.
// No SDK key at all is malformed: the environment would have nothing to authenticate with.
var malformed *MalformedCredentialSetError
_, err := NewAcceptedSetBuilder().
WithMobileKey(MobileKeyParams{Value: "mob"}).
WithEnvironmentID(config.EnvironmentID("env")).
Build()
require.ErrorIs(t, err, errAcceptedSetMissingSDKKey)
require.ErrorAs(t, err, &malformed)

// An SDK key with no designated anchor is malformed.
var malformed *MalformedCredentialSetError
_, err = NewAcceptedSetBuilder().WithSDKKey(SDKKeyParams{Value: "sdk"}).Build()
require.ErrorAs(t, err, &malformed)

Expand Down
16 changes: 10 additions & 6 deletions internal/envfactory/env_params.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,22 @@ type EnvironmentParams struct {

// AcceptedSDKKey is one entry in the accepted SDK key set for an environment.
// Expiry is zero if the key is permanent.
// HasViews is true if the SDK key is associated with a view.
type AcceptedSDKKey struct {
Key string
Value config.SDKKey
Expiry time.Time
Key string
Value config.SDKKey
Expiry time.Time
HasViews bool
}

// AcceptedMobileKey is one entry in the accepted mobile key set for an environment.
// Expiry is zero if the key is permanent.
// HasViews is true if the mobile key is associated with a view.
type AcceptedMobileKey struct {
Key string
Value config.MobileKey
Expiry time.Time
Key string
Value config.MobileKey
Expiry time.Time
HasViews bool
}

func (e EnvironmentParams) WithFilter(key config.FilterKey) EnvironmentParams {
Expand Down
17 changes: 10 additions & 7 deletions internal/envfactory/env_rep.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,10 @@ type ExpiringKeyRep struct {
// Key is the human-readable identifier (non-secret, e.g. "default-sdk"); Value is
// the credential secret (e.g. "sdk-xxxx-..."). See the EnvironmentRep TERMINOLOGY comment.
type ConcurrentKeyRep struct {
Key string `json:"key"`
Value string `json:"value"`
Expiry *int64 `json:"expiry,omitempty"` // Unix-ms; nil = permanent
Key string `json:"key"`
Value string `json:"value"`
Expiry *int64 `json:"expiry,omitempty"` // Unix-ms; nil = permanent
HasViews bool `json:"hasViews"`
}

func ToTime(millisecondTime ldtime.UnixMillisecondTime) time.Time {
Expand Down Expand Up @@ -135,8 +136,9 @@ func (r EnvironmentRep) ToParams() EnvironmentParams {
params.AcceptedSDKKeys = make([]AcceptedSDKKey, 0, len(r.SDKKeys))
for _, k := range r.SDKKeys {
entry := AcceptedSDKKey{
Key: k.Key,
Value: config.SDKKey(k.Value),
Key: k.Key,
Value: config.SDKKey(k.Value),
HasViews: k.HasViews,
}
if k.Expiry != nil {
entry.Expiry = time.UnixMilli(*k.Expiry)
Expand All @@ -162,8 +164,9 @@ func (r EnvironmentRep) ToParams() EnvironmentParams {
params.AcceptedMobileKeys = make([]AcceptedMobileKey, 0, len(r.MobileKeys))
for _, k := range r.MobileKeys {
entry := AcceptedMobileKey{
Key: k.Key,
Value: config.MobileKey(k.Value),
Key: k.Key,
Value: config.MobileKey(k.Value),
HasViews: k.HasViews,
}
if k.Expiry != nil {
entry.Expiry = time.UnixMilli(*k.Expiry)
Expand Down
61 changes: 61 additions & 0 deletions internal/envfactory/env_rep_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,67 @@ func TestEnvironmentRepNewFormatWithArrays(t *testing.T) {
assert.Equal(t, AcceptedMobileKey{Key: "mob-key-1", Value: config.MobileKey("mob-f41c")}, params.AcceptedMobileKeys[0])
}

// TestEnvironmentRepViewScopedKeys pins the hasViews wire contract on both arrays: it decodes onto
// ConcurrentKeyRep and is carried through ToParams onto the accepted entries, where BuildAcceptedSet
// consumes it.
//
// The absent-field case is the important one. hasViews is a plain bool, so an entry that omits it —
// every entry a backend that predates the field emits — decodes to false and is treated as not
// view-scoped. An explicit false is indistinguishable from absent, which is the intent: there is no
// third state.
func TestEnvironmentRepViewScopedKeys(t *testing.T) {
jsonStr := `{
"envID": "68e5179e8307e4099c277e2a",
"envKey": "production",
"envName": "Production",
"mobKey": "mob-primary",
"projKey": "my-project",
"projName": "My Project",
"sdkKey": { "value": "sdk-anchor" },
"sdkKeys": [
{ "key": "default-sdk", "value": "sdk-anchor" },
{ "key": "service-a", "value": "sdk-service-a", "hasViews": false },
{ "key": "view-scoped", "value": "sdk-viewy", "hasViews": true }
],
"mobileKeys": [
{ "key": "default-mob", "value": "mob-primary" },
{ "key": "view-scoped-mob", "value": "mob-viewy", "hasViews": true }
],
"version": 26
}`

var rep EnvironmentRep
require.NoError(t, json.Unmarshal([]byte(jsonStr), &rep))

require.Len(t, rep.SDKKeys, 3)
assert.False(t, rep.SDKKeys[0].HasViews, "an absent hasViews must decode to false")
assert.False(t, rep.SDKKeys[1].HasViews)
assert.True(t, rep.SDKKeys[2].HasViews)

require.Len(t, rep.MobileKeys, 2)
assert.False(t, rep.MobileKeys[0].HasViews)
assert.True(t, rep.MobileKeys[1].HasViews)

params := rep.ToParams()

require.Len(t, params.AcceptedSDKKeys, 3)
assert.Equal(t, AcceptedSDKKey{Key: "default-sdk", Value: config.SDKKey("sdk-anchor")}, params.AcceptedSDKKeys[0])
assert.Equal(t, AcceptedSDKKey{Key: "service-a", Value: config.SDKKey("sdk-service-a")}, params.AcceptedSDKKeys[1])
assert.Equal(t, AcceptedSDKKey{
Key: "view-scoped",
Value: config.SDKKey("sdk-viewy"),
HasViews: true,
}, params.AcceptedSDKKeys[2])

require.Len(t, params.AcceptedMobileKeys, 2)
assert.Equal(t, AcceptedMobileKey{Key: "default-mob", Value: config.MobileKey("mob-primary")}, params.AcceptedMobileKeys[0])
assert.Equal(t, AcceptedMobileKey{
Key: "view-scoped-mob",
Value: config.MobileKey("mob-viewy"),
HasViews: true,
}, params.AcceptedMobileKeys[1])
}

// TestEnvironmentRepOldFormatNoArrays verifies that an old-format payload (singular sdkKey/mobKey
// only, no sdkKeys/mobileKeys arrays) is normalized by ToParams() into a consistent accepted set.
// The wire rep's SDKKeys/MobileKeys remain nil, but params.AcceptedSDKKeys/AcceptedMobileKeys are
Expand Down
82 changes: 62 additions & 20 deletions internal/envfactory/reconcile_helper.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
package envfactory

import (
"github.com/launchdarkly/ld-relay/v8/config"
"github.com/launchdarkly/ld-relay/v8/internal/credential"
"github.com/launchdarkly/ld-relay/v8/internal/util"
)

// collectViewScopedValues returns the set of credential values that any entry marks as view-scoped,
// excluding the designated key (the anchor, or the primary mobile key) which is never filtered.
//
// Two entries can carry the same value with only one of them marked. Keying on the value means one
// marked entry rejects it, whichever position it holds.
func collectViewScopedValues[E any, V comparable](entries []E, designated V, get func(E) (V, bool)) map[V]bool {
var viewScoped map[V]bool
for _, e := range entries {
value, hasViews := get(e)
if !hasViews || value == designated {
continue
}
if viewScoped == nil {
viewScoped = make(map[V]bool)
}
Comment thread
aaron-zeisler marked this conversation as resolved.
viewScoped[value] = true
}
return viewScoped // nil is a valid empty set to read from
}

// BuildAcceptedSet converts an EnvironmentParams into the AcceptedSet needed by
// EnvContext.ReconcileCredentials.
//
Expand All @@ -20,33 +41,47 @@ import (
// The builder de-duplicates by value, so an anchor or primary mobile key that also appears in its
// array is added only once.
//
// A *credential.MalformedCredentialSetError is returned (with an empty AcceptedSet) for a
// structurally malformed payload: an undefined anchor (params.SDKKey not set), a defined anchor that
// is absent from params.AcceptedSDKKeys, a defined primary mobile key (params.MobileKey) that is
// absent from params.AcceptedMobileKeys, a non-empty params.AcceptedMobileKeys with no designated
// primary (params.MobileKey undefined), or an array entry with an empty value. The caller must
// preserve the previous accepted state and, for RAC handlers, reconnect the stream with jitter to
// force a fresh put. This is the single home for the anchor invariant.
func BuildAcceptedSet(params EnvironmentParams) (credential.AcceptedSet, error) {
// An error is returned (with an empty AcceptedSet) for a structurally malformed payload: an undefined
// anchor (params.SDKKey not set), a defined anchor that is absent from params.AcceptedSDKKeys, a
// defined primary mobile key (params.MobileKey) that is absent from params.AcceptedMobileKeys, a
// non-empty params.AcceptedMobileKeys with no designated primary (params.MobileKey undefined), an
// array entry with an empty value, or no usable SDK key at all. The caller must preserve the previous
// accepted state and, for RAC handlers, reconnect the stream with jitter to force a fresh put. This is
// the single home for the anchor invariant.
//
// Keys scoped to a view are filtered out here rather than at authentication time, making this the
// single funnel for both RAC and the offline archive. The second return value names the keys that were
// dropped, so callers can log what they lost. An SDK presenting one of them gets a 401: the key is
// simply absent from the lookup map.
func BuildAcceptedSet(params EnvironmentParams) (credential.AcceptedSet, []string, error) {
anchor := params.SDKKey
b := credential.NewAcceptedSetBuilder().WithEnvironmentID(params.EnvID)
var rejected []string

// Add every accepted SDK key, designating the anchor as we encounter it. WithAnchor both adds and
// designates, and forces the anchor permanent — so a payload that (wrongly) carries an expiry on
// the anchor's own entry cannot demote it. An undefined anchor never matches a (defined) array
// value, so it is never designated and Build returns a *MalformedCredentialSetError.
// value, so it is never designated and Build rejects the payload.
//
// Entries with an empty value are structurally malformed: relay would silently accept them but
// they can never authenticate any SDK. Reject loudly rather than produce a credential-short env.
viewScopedSDKValues := collectViewScopedValues(params.AcceptedSDKKeys, anchor,
func(k AcceptedSDKKey) (config.SDKKey, bool) { return k.Value, k.HasViews })

anchorInArray := false
for _, k := range params.AcceptedSDKKeys {
if !k.Value.Defined() {
return credential.AcceptedSet{}, credential.NewEmptyCredentialError("sdkKeys", k.Key)
return credential.AcceptedSet{}, nil, credential.NewEmptyCredentialError("sdkKeys", k.Key)
}
if k.Value == anchor {
switch {
// A marker on the anchor's own entry is disregarded: dropping the designated key would take the
// whole environment down, and the backend forbids views on a default key in the first place.
case k.Value == anchor:
anchorInArray = true
b.WithAnchor(credential.SDKKeyParams{Value: k.Value, Key: util.PtrOrNil(k.Key)})
} else {
case viewScopedSDKValues[k.Value]:
rejected = append(rejected, k.Key)
default:
b.WithSDKKey(credential.SDKKeyParams{Value: k.Value, Key: util.PtrOrNil(k.Key), Expiry: util.PtrOrNil(k.Expiry)})
}
}
Expand All @@ -55,21 +90,28 @@ func BuildAcceptedSet(params EnvironmentParams) (credential.AcceptedSet, error)
// synthesizes it into the array for old-format payloads). A defined anchor absent from the array is
// a structurally malformed payload — reject it.
if anchor.Defined() && !anchorInArray {
return credential.AcceptedSet{}, credential.NewAnchorNotInSetError()
return credential.AcceptedSet{}, nil, credential.NewAnchorNotInSetError()
}

// Add every accepted mobile key, designating the primary as we encounter it. Like the anchor,
// WithPrimaryMobileKey forces the primary permanent, so an expiry the payload may carry on the
// primary's own entry cannot demote it.
viewScopedMobileValues := collectViewScopedValues(params.AcceptedMobileKeys, params.MobileKey,
func(k AcceptedMobileKey) (config.MobileKey, bool) { return k.Value, k.HasViews })

primaryMobileInArray := false
for _, k := range params.AcceptedMobileKeys {
if !k.Value.Defined() {
return credential.AcceptedSet{}, credential.NewEmptyCredentialError("mobileKeys", k.Key)
return credential.AcceptedSet{}, nil, credential.NewEmptyCredentialError("mobileKeys", k.Key)
}
if k.Value == params.MobileKey {
switch {
// Like the anchor, a marker on the primary's own entry is disregarded rather than honored.
case k.Value == params.MobileKey:
primaryMobileInArray = true
b.WithPrimaryMobileKey(credential.MobileKeyParams{Value: k.Value, Key: util.PtrOrNil(k.Key)})
} else {
case viewScopedMobileValues[k.Value]:
rejected = append(rejected, k.Key)
default:
b.WithMobileKey(credential.MobileKeyParams{Value: k.Value, Key: util.PtrOrNil(k.Key), Expiry: util.PtrOrNil(k.Expiry)})
}
}
Expand All @@ -79,7 +121,7 @@ func BuildAcceptedSet(params EnvironmentParams) (credential.AcceptedSet, error)
// without this guard the primary would be silently left undesignated, clearing it on reconcile and
// breaking event forwarding. (An undefined mobKey is valid — a server-side-only environment.)
if params.MobileKey.Defined() && !primaryMobileInArray {
return credential.AcceptedSet{}, credential.NewPrimaryMobileKeyNotInSetError()
return credential.AcceptedSet{}, nil, credential.NewPrimaryMobileKeyNotInSetError()
}

// A non-empty mobileKeys[] with no designated primary (undefined mobKey) is malformed: the reconcile
Expand All @@ -89,12 +131,12 @@ func BuildAcceptedSet(params EnvironmentParams) (credential.AcceptedSet, error)
// server-side-only environment. Old-format payloads synthesize the array from mobKey only, so an
// undefined mobKey yields an empty array and is unaffected.)
if len(params.AcceptedMobileKeys) > 0 && !params.MobileKey.Defined() {
return credential.AcceptedSet{}, credential.NewPrimaryMobileKeyMissingError()
return credential.AcceptedSet{}, nil, credential.NewPrimaryMobileKeyMissingError()
}

set, err := b.Build()
if err != nil {
return credential.AcceptedSet{}, err
return credential.AcceptedSet{}, nil, err
}
return set, nil
return set, rejected, nil
}
Loading