Skip to content

Commit 3392e91

Browse files
reyortiz3claude
andauthored
Address Redis Cluster mode review follow-ups for #5153 (#5210)
Address Redis Cluster mode review follow-ups Three small follow-ups from the PR #5153 review that were deferred so the core cluster-mode support could land: - Document the cluster slot invariant for storeUpstreamTokensScript. The script reads oldUserID from KEYS[1] inside its body to keep the user-set bookkeeping atomic, so the user-set keys are constructed dynamically from ARGV[4] rather than declared as KEYS. The new comment makes the {ns:name} hash-tag requirement explicit, so a future refactor that rebuilds the prefix without the tag will be caught in review rather than silently regressing on standalone Redis and exploding with CROSSSLOT on a real cluster. - Filter SMEMBERS results in GetAllUpstreamTokens, DeleteUpstreamTokens, and GetLatestUpstreamTokensForUser to entries that share the storage instance's keyPrefix, warn-logging anything dropped. A stray un-prefixed member (legacy data, an external admin op, a test fixture) would today surface as CROSSSLOT under cluster while passing on standalone; this turns it into a logged warning. The defensive filter is a pure helper with table-driven tests, plus a behavior test that captures slog output to prove the wiring at all three call sites. - Align the operator CRD `tls` doc with the storage-layer comment so that crd-api.md and `kubectl explain` both describe TLS as applying to "Redis/Valkey master or cluster nodes" — previously cluster-mode users reading the CRD field would not realise this same field configured TLS for cluster-node connections. Regenerated CRD YAML, Helm templates, and crd-api.md to match. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c813153 commit 3392e91

8 files changed

Lines changed: 205 additions & 10 deletions

File tree

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -869,7 +869,7 @@ type RedisStorageConfig struct {
869869
// +optional
870870
WriteTimeout string `json:"writeTimeout,omitempty"`
871871

872-
// TLS configures TLS for connections to the Redis/Valkey master.
872+
// TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
873873
// Presence of this field enables TLS. Omit to use plaintext.
874874
// +optional
875875
TLS *RedisTLSConfig `json:"tls,omitempty"`

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ spec:
489489
type: object
490490
tls:
491491
description: |-
492-
TLS configures TLS for connections to the Redis/Valkey master.
492+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
493493
Presence of this field enables TLS. Omit to use plaintext.
494494
properties:
495495
caCertSecretRef:
@@ -1798,7 +1798,7 @@ spec:
17981798
type: object
17991799
tls:
18001800
description: |-
1801-
TLS configures TLS for connections to the Redis/Valkey master.
1801+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
18021802
Presence of this field enables TLS. Omit to use plaintext.
18031803
properties:
18041804
caCertSecretRef:

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ spec:
362362
type: object
363363
tls:
364364
description: |-
365-
TLS configures TLS for connections to the Redis/Valkey master.
365+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
366366
Presence of this field enables TLS. Omit to use plaintext.
367367
properties:
368368
caCertSecretRef:
@@ -3316,7 +3316,7 @@ spec:
33163316
type: object
33173317
tls:
33183318
description: |-
3319-
TLS configures TLS for connections to the Redis/Valkey master.
3319+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
33203320
Presence of this field enables TLS. Omit to use plaintext.
33213321
properties:
33223322
caCertSecretRef:

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ spec:
492492
type: object
493493
tls:
494494
description: |-
495-
TLS configures TLS for connections to the Redis/Valkey master.
495+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
496496
Presence of this field enables TLS. Omit to use plaintext.
497497
properties:
498498
caCertSecretRef:
@@ -1801,7 +1801,7 @@ spec:
18011801
type: object
18021802
tls:
18031803
description: |-
1804-
TLS configures TLS for connections to the Redis/Valkey master.
1804+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
18051805
Presence of this field enables TLS. Omit to use plaintext.
18061806
properties:
18071807
caCertSecretRef:

deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ spec:
365365
type: object
366366
tls:
367367
description: |-
368-
TLS configures TLS for connections to the Redis/Valkey master.
368+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
369369
Presence of this field enables TLS. Omit to use plaintext.
370370
properties:
371371
caCertSecretRef:
@@ -3319,7 +3319,7 @@ spec:
33193319
type: object
33203320
tls:
33213321
description: |-
3322-
TLS configures TLS for connections to the Redis/Valkey master.
3322+
TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
33233323
Presence of this field enables TLS. Omit to use plaintext.
33243324
properties:
33253325
caCertSecretRef:

docs/operator/crd-api.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/authserver/storage/redis.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"log/slog"
1212
"net/url"
1313
"slices"
14+
"strings"
1415
"time"
1516

1617
"github.com/ory/fosite"
@@ -52,6 +53,35 @@ func warnOnCleanupErr(err error, operation, key string) {
5253
}
5354
}
5455

56+
// filterIndexMembersByPrefix splits SMEMBERS results into members that share
57+
// this storage instance's key prefix and those that do not. Multi-key
58+
// operations like MGet and Del fail with CROSSSLOT under Redis Cluster when
59+
// any member hashes to a different slot, and a stray un-prefixed entry
60+
// (legacy data, an external admin op, a test fixture) is the most likely
61+
// source of such drift. Filtering at read time turns a hard cluster failure
62+
// into a logged warning, while remaining a no-op on standalone Redis.
63+
func filterIndexMembersByPrefix(prefix string, members []string) (kept, dropped []string) {
64+
for _, m := range members {
65+
if strings.HasPrefix(m, prefix) {
66+
kept = append(kept, m)
67+
} else {
68+
dropped = append(dropped, m)
69+
}
70+
}
71+
return kept, dropped
72+
}
73+
74+
// warnDroppedIndexMembers emits a warning for each foreign member returned by
75+
// filterIndexMembersByPrefix so operators can identify the source of cluster-
76+
// incompatible entries.
77+
func warnDroppedIndexMembers(operation, indexKey, expectedPrefix string, dropped []string) {
78+
for _, m := range dropped {
79+
slog.Warn("dropping foreign index member to prevent CROSSSLOT",
80+
"operation", operation, "indexKey", indexKey,
81+
"expectedPrefix", expectedPrefix, "member", m)
82+
}
83+
}
84+
5585
// RedisStorage implements the Storage interface backed by Redis.
5686
// Supports standalone mode (single endpoint), Sentinel failover mode, and
5787
// Cluster mode. It provides distributed storage for OAuth2 tokens, authorization
@@ -692,6 +722,17 @@ func (s *storedUpstreamTokens) toUpstreamTokens() *UpstreamTokens {
692722
// ARGV[2] = TTL in milliseconds
693723
// ARGV[3] = new UserID ("" if no user)
694724
// ARGV[4] = user upstream set key prefix (e.g. "thv:auth:{ns:name}:user:upstream:")
725+
//
726+
// Cluster slot invariant: this script reads oldUserID from KEYS[1] inside the
727+
// script body (atomic with the rest of the work), so the user-set keys are
728+
// constructed dynamically as `ARGV[4] .. userID` rather than being passed as
729+
// declared KEYS. Every dynamically-built key MUST therefore inherit the
730+
// `{ns:name}` hash tag that is baked into ARGV[4] (s.keyPrefix). All callers
731+
// derive ARGV[4] from s.keyPrefix, which DeriveKeyPrefix builds with the
732+
// `{ns:name}` hash tag, so user-set keys land on the same Redis Cluster slot
733+
// as KEYS[1] and KEYS[2]. A future refactor that strips the hash tag — or
734+
// rebuilds setPrefix from raw inputs without the tag — will silently pass on
735+
// standalone Redis and fail with CROSSSLOT under Cluster.
695736
var storeUpstreamTokensScript = redis.NewScript(`
696737
local oldUserID = ""
697738
local existing = redis.call('GET', KEYS[1])
@@ -899,6 +940,9 @@ func (s *RedisStorage) GetAllUpstreamTokens(ctx context.Context, sessionID strin
899940
return nil, fmt.Errorf("failed to get upstream token index: %w", err)
900941
}
901942

943+
providerKeys, dropped := filterIndexMembersByPrefix(s.keyPrefix, providerKeys)
944+
warnDroppedIndexMembers("GetAllUpstreamTokens", idxKey, s.keyPrefix, dropped)
945+
902946
if len(providerKeys) == 0 {
903947
return result, nil
904948
}
@@ -961,6 +1005,9 @@ func (s *RedisStorage) DeleteUpstreamTokens(ctx context.Context, sessionID strin
9611005
return fmt.Errorf("failed to get upstream token index: %w", err)
9621006
}
9631007

1008+
providerKeys, dropped := filterIndexMembersByPrefix(s.keyPrefix, providerKeys)
1009+
warnDroppedIndexMembers("DeleteUpstreamTokens", idxKey, s.keyPrefix, dropped)
1010+
9641011
if len(providerKeys) == 0 {
9651012
return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found"))
9661013
}
@@ -1017,6 +1064,10 @@ func (s *RedisStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userI
10171064
if err != nil && !errors.Is(err, redis.Nil) {
10181065
return nil, fmt.Errorf("failed to get user upstream index: %w", err)
10191066
}
1067+
1068+
members, dropped := filterIndexMembersByPrefix(s.keyPrefix, members)
1069+
warnDroppedIndexMembers("GetLatestUpstreamTokensForUser", setKey, s.keyPrefix, dropped)
1070+
10201071
if len(members) == 0 {
10211072
return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found"))
10221073
}

pkg/authserver/storage/redis_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
package storage
99

1010
import (
11+
"bytes"
1112
"context"
1213
"fmt"
14+
"log/slog"
1315
"net/url"
16+
"strings"
1417
"sync"
1518
"sync/atomic"
1619
"testing"
@@ -2723,3 +2726,144 @@ func runDCRConcurrentAccess(
27232726
assert.Zero(t, atomic.LoadInt32(&storeErrCount), "no concurrent Store should have errored")
27242727
assert.Zero(t, atomic.LoadInt32(&getErrCount), "no concurrent Get should have errored")
27252728
}
2729+
2730+
// captureWarnLogs swaps in a buffered slog handler at warn level for the
2731+
// duration of the test and returns the captured output. Process-global, not
2732+
// safe for t.Parallel().
2733+
func captureWarnLogs(t *testing.T) *bytes.Buffer {
2734+
t.Helper()
2735+
var buf bytes.Buffer
2736+
handler := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
2737+
orig := slog.Default()
2738+
slog.SetDefault(slog.New(handler))
2739+
t.Cleanup(func() { slog.SetDefault(orig) })
2740+
return &buf
2741+
}
2742+
2743+
// assertForeignDropWarn checks that the warn log emitted for a filtered
2744+
// CROSSSLOT-defending op names the operation, the dropped member, and the
2745+
// expected key prefix.
2746+
func assertForeignDropWarn(t *testing.T, out, operation, member, prefix string) {
2747+
t.Helper()
2748+
assert.Contains(t, out, "dropping foreign index member to prevent CROSSSLOT",
2749+
"expected warn log emitted by %s; got: %s", operation, out)
2750+
assert.Contains(t, out, member, "warn log should name the dropped member")
2751+
assert.Contains(t, out, operation, "warn log should name the operation")
2752+
assert.Contains(t, out, strings.TrimSuffix(prefix, ":"),
2753+
"warn log should reference the expected prefix")
2754+
}
2755+
2756+
// TestForeignMembersFilteredFromIndexOps verifies that the three multi-key
2757+
// SMEMBERS-fed operations (GetAllUpstreamTokens, DeleteUpstreamTokens,
2758+
// GetLatestUpstreamTokensForUser) drop foreign members before MGet/Del so a
2759+
// stray un-prefixed entry in an index set cannot escalate into a CROSSSLOT
2760+
// failure on Redis Cluster, and emit a warn log naming the dropped member.
2761+
//
2762+
// This test uses slog.SetDefault (process-global) and therefore does not run
2763+
// in parallel.
2764+
func TestForeignMembersFilteredFromIndexOps(t *testing.T) { //nolint:paralleltest // captures slog default
2765+
storage, mr := newTestRedisStorage(t)
2766+
t.Cleanup(func() {
2767+
_ = storage.Close()
2768+
mr.Close()
2769+
})
2770+
ctx := context.Background()
2771+
2772+
tokens := &UpstreamTokens{
2773+
ProviderID: "github",
2774+
AccessToken: "real-access",
2775+
RefreshToken: "real-refresh",
2776+
UserID: "user-A",
2777+
ExpiresAt: time.Now().Add(time.Hour),
2778+
}
2779+
require.NoError(t, storage.StoreUpstreamTokens(ctx, "session-X", "github", tokens))
2780+
2781+
sessionIdxKey := redisSetKey(storage.keyPrefix, KeyTypeUpstreamIdx, "session-X")
2782+
userIdxKey := redisSetKey(storage.keyPrefix, KeyTypeUserUpstream, "user-A")
2783+
const foreignMember = "other-tenant:auth:{ns:other}:upstream:s:p"
2784+
mr.SAdd(sessionIdxKey, foreignMember)
2785+
mr.SAdd(userIdxKey, foreignMember)
2786+
2787+
t.Run("GetAllUpstreamTokens", func(t *testing.T) {
2788+
buf := captureWarnLogs(t)
2789+
got, err := storage.GetAllUpstreamTokens(ctx, "session-X")
2790+
require.NoError(t, err)
2791+
require.Len(t, got, 1)
2792+
require.Contains(t, got, "github")
2793+
assert.Equal(t, "real-access", got["github"].AccessToken)
2794+
assertForeignDropWarn(t, buf.String(), "GetAllUpstreamTokens", foreignMember, storage.keyPrefix)
2795+
})
2796+
2797+
t.Run("GetLatestUpstreamTokensForUser", func(t *testing.T) {
2798+
buf := captureWarnLogs(t)
2799+
got, err := storage.GetLatestUpstreamTokensForUser(ctx, "user-A", "github")
2800+
require.NoError(t, err)
2801+
require.NotNil(t, got)
2802+
assert.Equal(t, "real-refresh", got.RefreshToken)
2803+
assertForeignDropWarn(t, buf.String(), "GetLatestUpstreamTokensForUser", foreignMember, storage.keyPrefix)
2804+
})
2805+
2806+
// DeleteUpstreamTokens runs last because it removes the index set.
2807+
t.Run("DeleteUpstreamTokens", func(t *testing.T) {
2808+
buf := captureWarnLogs(t)
2809+
require.NoError(t, storage.DeleteUpstreamTokens(ctx, "session-X"))
2810+
assertForeignDropWarn(t, buf.String(), "DeleteUpstreamTokens", foreignMember, storage.keyPrefix)
2811+
})
2812+
}
2813+
2814+
func TestFilterIndexMembersByPrefix(t *testing.T) {
2815+
t.Parallel()
2816+
2817+
tests := []struct {
2818+
name string
2819+
prefix string
2820+
members []string
2821+
wantKept []string
2822+
wantDropped []string
2823+
}{
2824+
{
2825+
name: "all members share prefix",
2826+
prefix: "thv:auth:{ns:name}:",
2827+
members: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"},
2828+
wantKept: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"},
2829+
wantDropped: nil,
2830+
},
2831+
{
2832+
name: "stray un-prefixed member is dropped",
2833+
prefix: "thv:auth:{ns:name}:",
2834+
members: []string{"thv:auth:{ns:name}:upstream:s1:p1", "legacy-key", "thv:auth:{ns:name}:upstream:s1:p2"},
2835+
wantKept: []string{"thv:auth:{ns:name}:upstream:s1:p1", "thv:auth:{ns:name}:upstream:s1:p2"},
2836+
wantDropped: []string{"legacy-key"},
2837+
},
2838+
{
2839+
name: "different-tenant member is dropped",
2840+
prefix: "thv:auth:{ns-a:srv}:",
2841+
members: []string{"thv:auth:{ns-a:srv}:upstream:s1:p1", "thv:auth:{ns-b:srv}:upstream:s1:p1"},
2842+
wantKept: []string{"thv:auth:{ns-a:srv}:upstream:s1:p1"},
2843+
wantDropped: []string{"thv:auth:{ns-b:srv}:upstream:s1:p1"},
2844+
},
2845+
{
2846+
name: "empty input",
2847+
prefix: "thv:auth:{ns:name}:",
2848+
members: nil,
2849+
wantKept: nil,
2850+
wantDropped: nil,
2851+
},
2852+
{
2853+
name: "all members dropped",
2854+
prefix: "thv:auth:{ns:name}:",
2855+
members: []string{"foo", "bar"},
2856+
wantKept: nil,
2857+
wantDropped: []string{"foo", "bar"},
2858+
},
2859+
}
2860+
2861+
for _, tc := range tests {
2862+
t.Run(tc.name, func(t *testing.T) {
2863+
t.Parallel()
2864+
kept, dropped := filterIndexMembersByPrefix(tc.prefix, tc.members)
2865+
assert.Equal(t, tc.wantKept, kept, "kept slice mismatch")
2866+
assert.Equal(t, tc.wantDropped, dropped, "dropped slice mismatch")
2867+
})
2868+
}
2869+
}

0 commit comments

Comments
 (0)