Skip to content
Open
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
3 changes: 2 additions & 1 deletion server/etcdmain/grpc_proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import (
"go.etcd.io/etcd/server/v3/etcdserver/api/v3election/v3electionpb"
"go.etcd.io/etcd/server/v3/etcdserver/api/v3lock/v3lockpb"
"go.etcd.io/etcd/server/v3/proxy/grpcproxy"
"go.etcd.io/etcd/server/v3/proxy/grpcproxy/cache"
)

var (
Expand Down Expand Up @@ -498,7 +499,7 @@ func newGRPCProxyServer(lg *zap.Logger, client *clientv3.Client) *grpc.Server {
leasep, _ := grpcproxy.NewLeaseProxy(client.Ctx(), client)

mainp := grpcproxy.NewMaintenanceProxy(client)
authp := grpcproxy.NewAuthProxy(client)
authp := grpcproxy.NewAuthProxy(client, kvp.(interface{ Cache() cache.Cache }).Cache())
electionp := grpcproxy.NewElectionProxy(client)
lockp := grpcproxy.NewLockProxy(client)

Expand Down
73 changes: 60 additions & 13 deletions server/proxy/grpcproxy/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,53 @@ import (

pb "go.etcd.io/etcd/api/v3/etcdserverpb"
clientv3 "go.etcd.io/etcd/client/v3"
"go.etcd.io/etcd/server/v3/proxy/grpcproxy/cache"
)

type AuthProxy struct {
authClient pb.AuthClient
cache cache.Cache
// we want compile errors if new methods are added
pb.UnsafeAuthServer
}

func NewAuthProxy(c *clientv3.Client) pb.AuthServer {
return &AuthProxy{authClient: pb.NewAuthClient(c.ActiveConnection())}
// NewAuthProxy returns an AuthServer that forwards auth RPCs to the backend.
// cache may be nil; when non-nil, any auth-mutating RPC flushes the cache so
// no caller can keep reading data cached under stale permissions.
//
// Note: the proxy only observes auth changes that pass through it. Permission
// changes applied directly against the backend will not flush the cache,
// leaving a same-token stale-read window until eviction.
func NewAuthProxy(c *clientv3.Client, cache cache.Cache) pb.AuthServer {
ap := &AuthProxy{authClient: pb.NewAuthClient(c.ActiveConnection())}
if cache != nil {
ap.cache = cache
}
return ap
}

func (ap *AuthProxy) AuthEnable(ctx context.Context, r *pb.AuthEnableRequest) (*pb.AuthEnableResponse, error) {
return ap.authClient.AuthEnable(ctx, r)
resp, err := ap.authClient.AuthEnable(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) AuthDisable(ctx context.Context, r *pb.AuthDisableRequest) (*pb.AuthDisableResponse, error) {
return ap.authClient.AuthDisable(ctx, r)
resp, err := ap.authClient.AuthDisable(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

// flushCacheOnMutation drops every cached entry after a successful auth
// mutation so no principal can keep reading data cached under stale
// permissions. Note that the proxy only observes auth changes that pass
// through it: permission changes applied directly against the backend
// bypass this hook and leave a stale-read window for the same token until
// the affected entries are evicted.
func (ap *AuthProxy) flushCacheOnMutation(err error) {
if err == nil && ap.cache != nil {
ap.cache.Clear()
}
}

func (ap *AuthProxy) AuthStatus(ctx context.Context, r *pb.AuthStatusRequest) (*pb.AuthStatusResponse, error) {
Expand All @@ -48,11 +77,15 @@ func (ap *AuthProxy) Authenticate(ctx context.Context, r *pb.AuthenticateRequest
}

func (ap *AuthProxy) RoleAdd(ctx context.Context, r *pb.AuthRoleAddRequest) (*pb.AuthRoleAddResponse, error) {
return ap.authClient.RoleAdd(ctx, r)
resp, err := ap.authClient.RoleAdd(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) RoleDelete(ctx context.Context, r *pb.AuthRoleDeleteRequest) (*pb.AuthRoleDeleteResponse, error) {
return ap.authClient.RoleDelete(ctx, r)
resp, err := ap.authClient.RoleDelete(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) RoleGet(ctx context.Context, r *pb.AuthRoleGetRequest) (*pb.AuthRoleGetResponse, error) {
Expand All @@ -64,19 +97,27 @@ func (ap *AuthProxy) RoleList(ctx context.Context, r *pb.AuthRoleListRequest) (*
}

func (ap *AuthProxy) RoleRevokePermission(ctx context.Context, r *pb.AuthRoleRevokePermissionRequest) (*pb.AuthRoleRevokePermissionResponse, error) {
return ap.authClient.RoleRevokePermission(ctx, r)
resp, err := ap.authClient.RoleRevokePermission(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) RoleGrantPermission(ctx context.Context, r *pb.AuthRoleGrantPermissionRequest) (*pb.AuthRoleGrantPermissionResponse, error) {
return ap.authClient.RoleGrantPermission(ctx, r)
resp, err := ap.authClient.RoleGrantPermission(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) UserAdd(ctx context.Context, r *pb.AuthUserAddRequest) (*pb.AuthUserAddResponse, error) {
return ap.authClient.UserAdd(ctx, r)
resp, err := ap.authClient.UserAdd(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) UserDelete(ctx context.Context, r *pb.AuthUserDeleteRequest) (*pb.AuthUserDeleteResponse, error) {
return ap.authClient.UserDelete(ctx, r)
resp, err := ap.authClient.UserDelete(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) UserGet(ctx context.Context, r *pb.AuthUserGetRequest) (*pb.AuthUserGetResponse, error) {
Expand All @@ -88,13 +129,19 @@ func (ap *AuthProxy) UserList(ctx context.Context, r *pb.AuthUserListRequest) (*
}

func (ap *AuthProxy) UserGrantRole(ctx context.Context, r *pb.AuthUserGrantRoleRequest) (*pb.AuthUserGrantRoleResponse, error) {
return ap.authClient.UserGrantRole(ctx, r)
resp, err := ap.authClient.UserGrantRole(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) UserRevokeRole(ctx context.Context, r *pb.AuthUserRevokeRoleRequest) (*pb.AuthUserRevokeRoleResponse, error) {
return ap.authClient.UserRevokeRole(ctx, r)
resp, err := ap.authClient.UserRevokeRole(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}

func (ap *AuthProxy) UserChangePassword(ctx context.Context, r *pb.AuthUserChangePasswordRequest) (*pb.AuthUserChangePasswordResponse, error) {
return ap.authClient.UserChangePassword(ctx, r)
resp, err := ap.authClient.UserChangePassword(ctx, r)
ap.flushCacheOnMutation(err)
return resp, err
}
57 changes: 47 additions & 10 deletions server/proxy/grpcproxy/cache/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cache

import (
"errors"
"math"
"sync"

"google.golang.org/protobuf/proto"
Expand All @@ -34,27 +35,51 @@ var (
)

type Cache interface {
Add(req *pb.RangeRequest, resp *pb.RangeResponse)
Get(req *pb.RangeRequest) (*pb.RangeResponse, error)
// Add stores resp for (req, authKey). authKey scopes the entry to a
// single caller identity so a cached response can never be replayed to
// a different principal; pass "" when the request carries no credentials.
Add(req *pb.RangeRequest, resp *pb.RangeResponse, authKey string)
// Get returns the cached response for (req, authKey).
Get(req *pb.RangeRequest, authKey string) (*pb.RangeResponse, error)
// Clear drops every cached entry. Used when the authorization
// configuration may have changed (user/role/permission updates) so no
// principal keeps reading data cached under stale permissions.
Clear()
Compact(revision int64)
Invalidate(key []byte, endkey []byte)
Size() int
Close()
}

// keyFunc returns the key of a request, which is used to look up its caching response in the cache.
func keyFunc(req *pb.RangeRequest) string {
// The caller identity (authKey) is mixed in so that a response cached for
// one authenticated user is never served to a different user.
func keyFunc(req *pb.RangeRequest, authKey string) string {
// TODO: use marshalTo to reduce allocation
b, err := proto.Marshal(req)
if err != nil {
panic(err)
}
return string(b)
// Pre-size the buffer to avoid repeated allocation while appending.
// proto.Marshal output is bounded by MaxRequestBytes (typically
// 1.5 MiB) and authKey is a bounded user identifier, so the sum
// cannot overflow on any supported platform. The explicit guard
// documents that invariant and silences CodeQL's allocation-size
// overflow heuristic.
if len(b) > math.MaxInt-len(authKey)-1 {
panic("request too large")
}
out := make([]byte, 0, len(b)+1+len(authKey))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
out = append(out, b...)
out = append(out, 0)
out = append(out, authKey...)
return string(out)
}

func NewCache(maxCacheEntries int) Cache {
return &cache{
lru: lru.New(maxCacheEntries),
maxEntries: maxCacheEntries,
cachedRanges: adt.NewIntervalTree(),
compactedRev: -1,
}
Expand All @@ -64,8 +89,9 @@ func (c *cache) Close() {}

// cache implements Cache
type cache struct {
mu sync.RWMutex
lru *lru.Cache
mu sync.RWMutex
lru *lru.Cache
maxEntries int

// a reverse index for cache invalidation
cachedRanges adt.IntervalTree
Expand All @@ -74,8 +100,8 @@ type cache struct {
}

// Add adds the response of a request to the cache if its revision is larger than the compacted revision of the cache.
func (c *cache) Add(req *pb.RangeRequest, resp *pb.RangeResponse) {
key := keyFunc(req)
func (c *cache) Add(req *pb.RangeRequest, resp *pb.RangeResponse, authKey string) {
key := keyFunc(req, authKey)

c.mu.Lock()
defer c.mu.Unlock()
Expand Down Expand Up @@ -113,8 +139,8 @@ func (c *cache) Add(req *pb.RangeRequest, resp *pb.RangeResponse) {

// Get looks up the caching response for a given request.
// Get is also responsible for lazy eviction when accessing compacted entries.
func (c *cache) Get(req *pb.RangeRequest) (*pb.RangeResponse, error) {
key := keyFunc(req)
func (c *cache) Get(req *pb.RangeRequest, authKey string) (*pb.RangeResponse, error) {
key := keyFunc(req, authKey)

c.mu.Lock()
defer c.mu.Unlock()
Expand All @@ -130,6 +156,17 @@ func (c *cache) Get(req *pb.RangeRequest) (*pb.RangeResponse, error) {
return nil, errors.New("not exist")
}

// Clear drops all cached entries and resets the reverse invalidation index.
// It is called when the authorization configuration may have changed so no
// principal can keep reading data cached under stale permissions.
func (c *cache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()

c.lru = lru.New(c.maxEntries)
c.cachedRanges = adt.NewIntervalTree()
}

// Invalidate invalidates the cache entries that intersecting with the given range from key to endkey.
func (c *cache) Invalidate(key, endkey []byte) {
c.mu.Lock()
Expand Down
119 changes: 119 additions & 0 deletions server/proxy/grpcproxy/cache/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright 2026 The etcd Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cache

import (
"testing"

pb "go.etcd.io/etcd/api/v3/etcdserverpb"
"go.etcd.io/etcd/api/v3/mvccpb"
)

func TestCacheAuthKeyIsolation(t *testing.T) {
c := NewCache(10)
defer c.Close()

req := &pb.RangeRequest{Key: []byte("secret"), Serializable: true}
resp := &pb.RangeResponse{
Header: &pb.ResponseHeader{Revision: 1},
Kvs: []*mvccpb.KeyValue{{Key: []byte("secret"), Value: []byte("s3cr3t")}},
}

// Store under identity "alice"
c.Add(req, resp, "alice")

// Same identity must hit
got, err := c.Get(req, "alice")
if err != nil {
t.Fatalf("expected cache hit for same identity, got %v", err)
}
if string(got.Kvs[0].Value) != "s3cr3t" {
t.Fatalf("unexpected value %q", got.Kvs[0].Value)
}

// Different identity must miss
_, err = c.Get(req, "bob")
if err == nil {
t.Fatal("expected cache miss for different identity")
}

// Empty identity must miss
_, err = c.Get(req, "")
if err == nil {
t.Fatal("expected cache miss for empty identity")
}

// Store under empty identity; other identities still miss
c.Add(req, resp, "")
if _, err = c.Get(req, ""); err != nil {
t.Fatalf("expected cache hit for empty identity, got %v", err)
}
// "alice" hits because her earlier Add(req,resp,"alice") entry is still
// resident, not because "" collides with a named identity.
if _, err = c.Get(req, "alice"); err != nil {
t.Fatalf("expected cache hit for alice after empty-identity add (same key), got %v", err)
}
}

func TestCacheClear(t *testing.T) {
c := NewCache(10)
defer c.Close()

req := &pb.RangeRequest{Key: []byte("k"), Serializable: true}
resp := &pb.RangeResponse{Header: &pb.ResponseHeader{Revision: 1}}

c.Add(req, resp, "alice")
c.Add(req, resp, "bob")
c.Add(req, resp, "")

if size := c.Size(); size != 3 {
t.Fatalf("expected 3 entries, got %d", size)
}

c.Clear()

if size := c.Size(); size != 0 {
t.Fatalf("expected 0 entries after Clear, got %d", size)
}
for _, id := range []string{"alice", "bob", ""} {
if _, err := c.Get(req, id); err == nil {
t.Fatalf("expected miss after Clear for identity %q", id)
}
}

// Cache must remain usable after Clear
c.Add(req, resp, "alice")
if _, err := c.Get(req, "alice"); err != nil {
t.Fatalf("expected hit after re-Add, got %v", err)
}
}

func TestKeyFuncDeterministic(t *testing.T) {
req := &pb.RangeRequest{Key: []byte("a"), Serializable: true}
k1 := keyFunc(req, "alice")
k2 := keyFunc(req, "alice")
k3 := keyFunc(req, "bob")
k4 := keyFunc(req, "")

if k1 != k2 {
t.Fatal("same inputs must produce same key")
}
if k1 == k3 {
t.Fatal("different identities must produce different keys")
}
if k1 == k4 {
t.Fatal("identity vs no-identity must produce different keys")
}
}
Loading