From 259071c09b8899a62cec43434a37e69262dc115a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E4=B8=AB=E8=AE=B2=E6=A2=B5?= Date: Fri, 4 Sep 2026 20:24:02 +0800 Subject: [PATCH] feat(etcd): restrict key browsing and writes by auth permissions --- agents/drivers/etcd-go/auth.go | 199 +++++++++++++++- agents/drivers/etcd-go/client.go | 80 ++++++- agents/drivers/etcd-go/integration_test.go | 3 + agents/drivers/etcd-go/kv.go | 106 +++++++++ agents/drivers/etcd-go/main.go | 4 + agents/drivers/etcd-go/main_test.go | 177 ++++++++++++++ agents/drivers/etcd-go/status.go | 5 + agents/drivers/etcd-go/watch.go | 9 +- .../src/components/etcd/EtcdKeyBrowser.vue | 96 ++++++-- .../etcd/__tests__/EtcdKeyBrowser.spec.ts | 77 ++++++- .../src/components/kv/KvKeyBrowser.vue | 51 +++- .../kv/__tests__/KvKeyBrowserLayout.spec.ts | 2 +- .../src/components/layout/ContentArea.vue | 15 +- .../sidebar/SidebarTreeRuntimeHost.vue | 34 +-- .../SidebarTreeRuntimeHost.expansion.spec.ts | 92 ++++++++ .../TreeItem.loadMoreActivation.spec.ts | 22 ++ apps/desktop/src/i18n/locales/en.ts | 3 + apps/desktop/src/i18n/locales/es.ts | 3 + apps/desktop/src/i18n/locales/it.ts | 3 + apps/desktop/src/i18n/locales/ja.ts | 3 + apps/desktop/src/i18n/locales/ko.ts | 3 + apps/desktop/src/i18n/locales/pt-BR.ts | 3 + apps/desktop/src/i18n/locales/zh-CN.ts | 3 + apps/desktop/src/i18n/locales/zh-TW.ts | 3 + .../connection/etcdConnectionDialog.spec.ts | 4 +- .../__tests__/sidebar/treeNodeClick.spec.ts | 12 + apps/desktop/src/lib/backend/tauri.ts | 1 + .../lib/etcd/__tests__/keyPermissions.spec.ts | 37 +++ apps/desktop/src/lib/etcd/keyPermissions.ts | 45 ++++ apps/desktop/src/lib/sidebar/treeNodeClick.ts | 4 +- .../connectionStore.etcdAccess.spec.ts | 217 ++++++++++++++++++ apps/desktop/src/stores/connectionStore.ts | 188 ++++++++++++--- 32 files changed, 1404 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/lib/etcd/__tests__/keyPermissions.spec.ts create mode 100644 apps/desktop/src/lib/etcd/keyPermissions.ts create mode 100644 apps/desktop/src/stores/__tests__/connectionStore.etcdAccess.spec.ts diff --git a/agents/drivers/etcd-go/auth.go b/agents/drivers/etcd-go/auth.go index ba557a9a59..6c87eb6d17 100644 --- a/agents/drivers/etcd-go/auth.go +++ b/agents/drivers/etcd-go/auth.go @@ -1,13 +1,180 @@ package main import ( + "bytes" "encoding/json" + "errors" "fmt" + "sort" "strings" + "time" clientv3 "go.etcd.io/etcd/client/v3" ) +// etcdReadRange is a half-open Key range that the current user can read. +// It is derived from the user's roles rather than asking etcd to enumerate +// the global Key space, which a restricted account is not allowed to do. +type etcdReadRange struct { + start string + end string +} + +// etcdReadAccess is a short-lived snapshot of the current user's readable +// ranges. etcd remains the authorization boundary for every Key request; the +// cache only avoids repeating AuthStatus, UserGet, and RoleGet while browsing. +type etcdReadAccess struct { + allKeys bool + ranges []etcdReadRange + expiresAt time.Time +} + +// etcd uses a single NUL byte as an unbounded range end, meaning every Key +// greater than or equal to the range start. It is a protocol sentinel, not a +// byte-string upper bound, so ordinary lexical comparison is incorrect. +const unboundedRangeEnd = "\x00" + +// readableKeyRanges returns whether the user can read every Key and otherwise +// the smallest set of non-overlapping read ranges granted by its roles. +func (s *etcdSession) readableKeyRanges() (bool, []etcdReadRange, error) { + if allKeys, ranges, ok := s.cachedReadAccess(time.Now()); ok { + return allKeys, ranges, nil + } + s.clientMu.Lock() + username := s.username + authEnabled := s.authEnabled + s.clientMu.Unlock() + client, clientErr := s.activeClient() + if clientErr == nil { + authEnabled = s.refreshAuthEnabled(client) + } + if !authEnabled || username == "root" { + s.cacheReadAccess(true, nil) + return true, nil, nil + } + if username == "" { + s.cacheReadAccess(false, nil) + return false, nil, nil + } + if clientErr != nil { + return false, nil, clientErr + } + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + user, err := client.Auth.UserGet(ctx, username) + if err != nil { + if isAuthenticationNotEnabled(err) { + s.disableAuth() + s.cacheReadAccess(true, nil) + return true, nil, nil + } + return false, nil, err + } + + ranges := make([]etcdReadRange, 0) + for _, roleName := range user.Roles { + if roleName == "root" { + s.cacheReadAccess(true, nil) + return true, nil, nil + } + role, err := client.Auth.RoleGet(ctx, roleName) + if err != nil { + if isAuthenticationNotEnabled(err) { + s.disableAuth() + s.cacheReadAccess(true, nil) + return true, nil, nil + } + return false, nil, err + } + for _, permission := range role.Perm { + if strings.EqualFold(permission.PermType.String(), "WRITE") { + continue + } + start := string(permission.Key) + end := string(permission.RangeEnd) + if isAllKeyPermission(start, end) { + s.cacheReadAccess(true, nil) + return true, nil, nil + } + if end == "" { + // An exact Key permission is represented as a one-Key range. + end = start + "\x00" + } + ranges = append(ranges, etcdReadRange{start: start, end: end}) + } + } + ranges = normalizeReadRanges(ranges) + s.cacheReadAccess(false, ranges) + return false, ranges, nil +} + +func (s *etcdSession) cachedReadAccess(now time.Time) (bool, []etcdReadRange, bool) { + s.clientMu.Lock() + defer s.clientMu.Unlock() + if s.readAccess == nil || !now.Before(s.readAccess.expiresAt) { + return false, nil, false + } + return s.readAccess.allKeys, append([]etcdReadRange(nil), s.readAccess.ranges...), true +} + +func (s *etcdSession) cacheReadAccess(allKeys bool, ranges []etcdReadRange) { + s.clientMu.Lock() + s.readAccess = &etcdReadAccess{ + allKeys: allKeys, + ranges: append([]etcdReadRange(nil), ranges...), + expiresAt: time.Now().Add(readAccessCacheTTL), + } + s.clientMu.Unlock() +} + +func isAllKeyPermission(start, end string) bool { + return (start == "" && (end == "" || end == unboundedRangeEnd)) || (start == unboundedRangeEnd && end == unboundedRangeEnd) +} + +func isUnboundedRangeEnd(end string) bool { + return end == unboundedRangeEnd +} + +func rangeStartAtOrAfterEnd(start, end string) bool { + return !isUnboundedRangeEnd(end) && bytes.Compare([]byte(start), []byte(end)) >= 0 +} + +func rangeEndGreater(left, right string) bool { + if left == right { + return false + } + if isUnboundedRangeEnd(left) { + return true + } + if isUnboundedRangeEnd(right) { + return false + } + return bytes.Compare([]byte(left), []byte(right)) > 0 +} + +func normalizeReadRanges(ranges []etcdReadRange) []etcdReadRange { + sort.Slice(ranges, func(i, j int) bool { + return bytes.Compare([]byte(ranges[i].start), []byte(ranges[j].start)) < 0 + }) + result := make([]etcdReadRange, 0, len(ranges)) + for _, candidate := range ranges { + if candidate.end == "" || rangeStartAtOrAfterEnd(candidate.start, candidate.end) { + continue + } + if len(result) > 0 { + last := &result[len(result)-1] + if isUnboundedRangeEnd(last.end) || bytes.Compare([]byte(candidate.start), []byte(last.end)) <= 0 { + if rangeEndGreater(candidate.end, last.end) { + last.end = candidate.end + } + continue + } + } + result = append(result, candidate) + } + return result +} + func (s *etcdSession) authUserList(params map[string]json.RawMessage) (any, error) { client, err := s.activeClient() if err != nil { @@ -23,21 +190,39 @@ func (s *etcdSession) authUserList(params map[string]json.RawMessage) (any, erro } func (s *etcdSession) authUserGet(params map[string]json.RawMessage) (any, error) { - user, err := requiredString(params, "user") - if err != nil { - return nil, err + user := strings.TrimSpace(stringOrDefault(params, "user", "")) + currentUserRequest := user == "" + s.clientMu.Lock() + authUsername := s.username + authEnabled := s.authEnabled + s.clientMu.Unlock() + client, clientErr := s.activeClient() + if currentUserRequest && clientErr == nil { + authEnabled = s.refreshAuthEnabled(client) } - client, err := s.activeClient() - if err != nil { - return nil, err + if user == "" { + user = authUsername + } + if currentUserRequest && !authEnabled { + return map[string]any{"user": user, "roles": []string{}, "authEnabled": false}, nil + } + if user == "" { + return nil, errors.New("user is required") + } + if clientErr != nil { + return nil, clientErr } ctx, cancel := s.beginOperation() defer s.endOperation(cancel) response, err := client.Auth.UserGet(ctx, user) if err != nil { + if currentUserRequest && isAuthenticationNotEnabled(err) { + s.disableAuth() + return map[string]any{"user": user, "roles": []string{}, "authEnabled": false}, nil + } return nil, err } - return map[string]any{"user": user, "roles": response.Roles}, nil + return map[string]any{"user": user, "roles": response.Roles, "authEnabled": true}, nil } func (s *etcdSession) authUserAdd(params map[string]json.RawMessage) (any, error) { diff --git a/agents/drivers/etcd-go/client.go b/agents/drivers/etcd-go/client.go index 92061a1e63..5702a0e0cd 100644 --- a/agents/drivers/etcd-go/client.go +++ b/agents/drivers/etcd-go/client.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" clientv3 "go.etcd.io/etcd/client/v3" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -72,7 +73,7 @@ func (s *etcdSession) connect(params map[string]json.RawMessage) (any, error) { if err != nil { return nil, err } - nextClient, err := buildClient(connection) + nextClient, authUsername, err := buildClient(connection) if err != nil { return nil, err } @@ -81,10 +82,13 @@ func (s *etcdSession) connect(params map[string]json.RawMessage) (any, error) { _ = nextClient.Close() return nil, err } + authEnabled := detectAuthEnabled(nextClient, authUsername) s.close() s.clientMu.Lock() s.client = nextClient s.connectedEndpoints = endpointList + s.username = authUsername + s.authEnabled = authEnabled s.clientMu.Unlock() return map[string]bool{"ok": true}, nil } @@ -127,6 +131,9 @@ func (s *etcdSession) close() error { client := s.client s.client = nil s.connectedEndpoints = nil + s.username = "" + s.authEnabled = false + s.readAccess = nil s.clientMu.Unlock() if client != nil { _ = client.Close() @@ -134,8 +141,9 @@ func (s *etcdSession) close() error { return nil } -func buildClient(connection connectionParams) (*clientv3.Client, error) { +func buildClient(connection connectionParams) (*clientv3.Client, string, error) { endpoints := connectionEndpoints(connection) + authUsername := strings.TrimSpace(connection.Username) config := clientv3.Config{ Endpoints: endpoints, DialTimeout: time.Duration(connectTimeoutSeconds(connection)) * time.Second, @@ -151,11 +159,62 @@ func buildClient(connection connectionParams) (*clientv3.Client, error) { if connection.SSL { tlsConfig, err := tlsConfigFor(connection) if err != nil { - return nil, err + return nil, "", err } config.TLS = tlsConfig + if authUsername == "" { + authUsername = clientCertificateUsername(tlsConfig) + } } - return clientv3.New(config) + client, err := clientv3.New(config) + return client, authUsername, err +} + +func detectAuthEnabled(client *clientv3.Client, authUsername string) bool { + enabled, err := probeAuthEnabled(client) + if err == nil { + return enabled + } + // AuthStatus was added after the first v3 releases. For an older server, + // configured credentials or a certificate identity are the safest signal. + return authUsername != "" +} + +func probeAuthEnabled(client *clientv3.Client) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) + defer cancel() + statusResponse, err := client.Auth.AuthStatus(ctx) + if err != nil { + return false, err + } + return statusResponse.Enabled, nil +} + +// refreshAuthEnabled keeps long-lived sessions in sync when an administrator +// enables or disables etcd Auth after DBX connected. A failed compatibility +// probe retains the last known state instead of flipping privileges. +func (s *etcdSession) refreshAuthEnabled(client *clientv3.Client) bool { + enabled, err := probeAuthEnabled(client) + s.clientMu.Lock() + defer s.clientMu.Unlock() + if err == nil && s.client == client { + if s.authEnabled != enabled { + s.readAccess = nil + } + s.authEnabled = enabled + } + return s.authEnabled +} + +func isAuthenticationNotEnabled(err error) bool { + return errors.Is(err, rpctypes.ErrAuthNotEnabled) +} + +func (s *etcdSession) disableAuth() { + s.clientMu.Lock() + s.authEnabled = false + s.readAccess = nil + s.clientMu.Unlock() } func connectTimeoutSeconds(connection connectionParams) int { @@ -234,11 +293,24 @@ func tlsConfigFor(connection connectionParams) (*tls.Config, error) { if err != nil { return nil, err } + if len(pair.Certificate) > 0 { + pair.Leaf, err = x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return nil, err + } + } tlsConfig.Certificates = []tls.Certificate{pair} } return tlsConfig, nil } +func clientCertificateUsername(config *tls.Config) string { + if config == nil || len(config.Certificates) == 0 || config.Certificates[0].Leaf == nil { + return "" + } + return strings.TrimSpace(config.Certificates[0].Leaf.Subject.CommonName) +} + func probeClient(client *clientv3.Client, endpoints []string) (map[string]any, error) { var lastFailure error for _, endpoint := range endpoints { diff --git a/agents/drivers/etcd-go/integration_test.go b/agents/drivers/etcd-go/integration_test.go index c0fe8de449..e4d2bf7cf2 100644 --- a/agents/drivers/etcd-go/integration_test.go +++ b/agents/drivers/etcd-go/integration_test.go @@ -388,6 +388,9 @@ func TestLiveEtcdAgent(t *testing.T) { if len(members) == 0 || members[0]["reachable"] != true || members[0]["version"] == nil { t.Fatalf("unexpected member rows: %#v", members) } + if errors, ok := members[0]["errors"].([]string); !ok || len(errors) != 0 { + t.Fatalf("successful member must include an empty errors list: %#v", members[0]) + } if keyCount, err := strconvParse(statusRow["keyCount"].(string)); err != nil || keyCount <= 0 { t.Fatalf("keyCount must be a positive numeric string: %#v", statusRow["keyCount"]) } diff --git a/agents/drivers/etcd-go/kv.go b/agents/drivers/etcd-go/kv.go index beb4459cc9..0896304c33 100644 --- a/agents/drivers/etcd-go/kv.go +++ b/agents/drivers/etcd-go/kv.go @@ -1,11 +1,13 @@ package main import ( + "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" + "sort" "strconv" "strings" "time" @@ -18,6 +20,10 @@ import ( const defaultListLimit = 100 const preserveLeaseMaxAttempts = 3 +type etcdRangeGetter interface { + Get(context.Context, string, ...clientv3.OpOption) (*clientv3.GetResponse, error) +} + func (s *etcdSession) listPrefix(params map[string]json.RawMessage) (any, error) { client, err := s.activeClient() if err != nil { @@ -40,6 +46,14 @@ func (s *etcdSession) listPrefix(params map[string]json.RawMessage) (any, error) } start = string(decoded) } + allKeys, readableRanges, err := s.readableKeyRanges() + if err != nil { + return nil, err + } + if !allKeys { + readableRanges = intersectReadRanges(readableRanges, prefixStart(prefix), prefixEnd(prefix)) + return s.listReadableRanges(client, readableRanges, start, limit, revision, includeValues) + } ctx, cancel := s.beginOperation() defer s.endOperation(cancel) @@ -77,6 +91,98 @@ func (s *etcdSession) listPrefix(params map[string]json.RawMessage) (any, error) return result, nil } +// intersectReadRanges limits granted ranges to the requested prefix range. +// etcd authorizes the complete range in a Range request, so querying a broad +// prefix directly would be rejected even when it contains readable subranges. +func intersectReadRanges(ranges []etcdReadRange, start, end string) []etcdReadRange { + intersections := make([]etcdReadRange, 0, len(ranges)) + for _, granted := range ranges { + candidate := granted + if bytes.Compare([]byte(start), []byte(candidate.start)) > 0 { + candidate.start = start + } + if rangeEndGreater(candidate.end, end) { + candidate.end = end + } + if candidate.end == "" || rangeStartAtOrAfterEnd(candidate.start, candidate.end) { + continue + } + intersections = append(intersections, candidate) + } + return normalizeReadRanges(intersections) +} + +// listReadableRanges pages through the union of the ranges granted to a +// restricted user. Each range is queried independently, so etcd never sees an +// unauthorized global range request. +func (s *etcdSession) listReadableRanges(client etcdRangeGetter, ranges []etcdReadRange, start string, limit int, revision *int64, includeValues bool) (any, error) { + ctx, cancel := s.beginOperation() + defer s.endOperation(cancel) + + byKey := make(map[string]*mvccpb.KeyValue) + var snapshotRevision int64 + if revision != nil && *revision > 0 { + snapshotRevision = *revision + } + for _, keyRange := range ranges { + requestStart := keyRange.start + if bytes.Compare([]byte(start), []byte(requestStart)) > 0 { + requestStart = start + } + if rangeStartAtOrAfterEnd(requestStart, keyRange.end) { + continue + } + options := []clientv3.OpOption{ + clientv3.WithRange(keyRange.end), + clientv3.WithLimit(int64(limit + 1)), + clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend), + } + if snapshotRevision > 0 { + options = append(options, clientv3.WithRev(snapshotRevision)) + } + response, err := client.Get(ctx, requestStart, options...) + if err != nil { + return nil, err + } + if snapshotRevision == 0 { + snapshotRevision = response.Header.Revision + } + for _, item := range response.Kvs { + byKey[string(item.Key)] = item + } + } + + items := make([]*mvccpb.KeyValue, 0, len(byKey)) + for _, item := range byKey { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + return bytes.Compare(items[i].Key, items[j].Key) < 0 + }) + more := len(items) > limit + if more { + items = items[:limit] + } + + keys := make([]any, 0, len(items)) + for _, item := range items { + row := metadataMap(item) + row["key"] = displayBytes(item.Key) + row["keyBytes"] = bytesObject(item.Key) + if includeValues { + row["value"] = valueObject(item.Value) + } + keys = append(keys, row) + } + result := map[string]any{"keys": keys, "revision": longString(snapshotRevision)} + if more && len(items) > 0 { + result["continuation"] = nextContinuation(items[len(items)-1].Key) + } else { + result["continuation"] = nil + } + return result, nil +} + func (s *etcdSession) get(params map[string]json.RawMessage) (any, error) { client, err := s.activeClient() if err != nil { diff --git a/agents/drivers/etcd-go/main.go b/agents/drivers/etcd-go/main.go index e9556a34e1..1e4a5f4494 100644 --- a/agents/drivers/etcd-go/main.go +++ b/agents/drivers/etcd-go/main.go @@ -21,6 +21,7 @@ const ( legacyAgentSessionID = "__legacy__" maxAgentSessions = 256 rpcTimeoutSeconds = 30 + readAccessCacheTTL = 15 * time.Second ) var capabilities = []string{ @@ -49,6 +50,9 @@ type etcdSession struct { clientMu sync.Mutex client *clientv3.Client connectedEndpoints []string + username string + authEnabled bool + readAccess *etcdReadAccess watchesMu sync.Mutex watches map[string]*watchState diff --git a/agents/drivers/etcd-go/main_test.go b/agents/drivers/etcd-go/main_test.go index c1775aa86b..f95e3f886d 100644 --- a/agents/drivers/etcd-go/main_test.go +++ b/agents/drivers/etcd-go/main_test.go @@ -1,12 +1,22 @@ package main import ( + "context" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" "encoding/json" "strings" "testing" + "time" + "go.etcd.io/etcd/api/v3/etcdserverpb" "go.etcd.io/etcd/api/v3/mvccpb" + "go.etcd.io/etcd/api/v3/v3rpc/rpctypes" + clientv3 "go.etcd.io/etcd/client/v3" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func TestHandshakeAdvertisesCapabilities(t *testing.T) { @@ -165,6 +175,117 @@ func TestTLSConfigRequiresCertAndKeyTogether(t *testing.T) { } } +func TestClientCertificateUsername(t *testing.T) { + config := &tls.Config{Certificates: []tls.Certificate{{Leaf: &x509.Certificate{Subject: pkix.Name{CommonName: " cert-reader "}}}}} + if got := clientCertificateUsername(config); got != "cert-reader" { + t.Fatalf("clientCertificateUsername() = %q, want cert-reader", got) + } + if got := clientCertificateUsername(&tls.Config{}); got != "" { + t.Fatalf("empty certificate username = %q, want empty", got) + } +} + +func TestReadableKeyRangesUsesAuthStatusAndEffectiveIdentity(t *testing.T) { + withoutAuth := newEtcdSession() + withoutAuth.username = "cert-reader" + allKeys, ranges, err := withoutAuth.readableKeyRanges() + if err != nil || !allKeys || len(ranges) != 0 { + t.Fatalf("disabled auth should allow all keys: all=%v ranges=%v err=%v", allKeys, ranges, err) + } + + missingIdentity := newEtcdSession() + missingIdentity.authEnabled = true + allKeys, ranges, err = missingIdentity.readableKeyRanges() + if err != nil || allKeys || len(ranges) != 0 { + t.Fatalf("enabled auth without an identity should expose no ranges: all=%v ranges=%v err=%v", allKeys, ranges, err) + } +} + +func TestReadAccessCacheExpiresAndReturnsACopy(t *testing.T) { + state := newEtcdSession() + state.cacheReadAccess(false, []etcdReadRange{{start: "/team-a/", end: "/team-a0"}}) + + allKeys, ranges, ok := state.cachedReadAccess(time.Now()) + if !ok || allKeys || len(ranges) != 1 || ranges[0].start != "/team-a/" { + t.Fatalf("cached read access = all=%v ranges=%#v ok=%v", allKeys, ranges, ok) + } + ranges[0].start = "/mutated/" + _, ranges, ok = state.cachedReadAccess(time.Now()) + if !ok || ranges[0].start != "/team-a/" { + t.Fatalf("cached range was not copied: %#v", ranges) + } + + state.clientMu.Lock() + state.readAccess.expiresAt = time.Now().Add(-time.Second) + state.clientMu.Unlock() + if _, _, ok := state.cachedReadAccess(time.Now()); ok { + t.Fatal("expired read access cache was returned") + } +} + +func TestAuthenticationNotEnabledFallbackClearsCachedRestrictions(t *testing.T) { + if !isAuthenticationNotEnabled(rpctypes.ErrAuthNotEnabled) { + t.Fatal("authentication-not-enabled status was not recognized") + } + if isAuthenticationNotEnabled(status.Error(codes.PermissionDenied, "authentication is not enabled")) { + t.Fatal("permission-denied status must not disable authentication") + } + + state := newEtcdSession() + state.authEnabled = true + state.cacheReadAccess(false, []etcdReadRange{{start: "/team-a/", end: "/team-a0"}}) + state.disableAuth() + if state.authEnabled { + t.Fatal("authentication remained enabled after compatibility fallback") + } + if _, _, ok := state.cachedReadAccess(time.Now()); ok { + t.Fatal("compatibility fallback retained stale restricted ranges") + } +} + +func TestAuthUserGetReportsDisabledAuthWithoutAClient(t *testing.T) { + state := newEtcdSession() + state.username = "cert-reader" + result, err := state.authUserGet(map[string]json.RawMessage{}) + if err != nil { + t.Fatalf("authUserGet() failed: %v", err) + } + detail := result.(map[string]any) + if detail["user"] != "cert-reader" || detail["authEnabled"] != false { + t.Fatalf("unexpected auth detail: %#v", detail) + } +} + +type recordingRangeGetter struct { + responses []*clientv3.GetResponse + revisions []int64 +} + +func (g *recordingRangeGetter) Get(_ context.Context, key string, options ...clientv3.OpOption) (*clientv3.GetResponse, error) { + op := clientv3.OpGet(key, options...) + g.revisions = append(g.revisions, op.Rev()) + response := g.responses[0] + g.responses = g.responses[1:] + return response, nil +} + +func TestListReadableRangesPinsFirstResponseRevision(t *testing.T) { + getter := &recordingRangeGetter{responses: []*clientv3.GetResponse{ + {Header: &etcdserverpb.ResponseHeader{Revision: 10}, Kvs: []*mvccpb.KeyValue{{Key: []byte("a")}}}, + {Header: &etcdserverpb.ResponseHeader{Revision: 12}, Kvs: []*mvccpb.KeyValue{{Key: []byte("z")}}}, + }} + result, err := newEtcdSession().listReadableRanges(getter, []etcdReadRange{{start: "a", end: "b"}, {start: "z", end: "zz"}}, "\x00", 10, nil, false) + if err != nil { + t.Fatalf("listReadableRanges() failed: %v", err) + } + if len(getter.revisions) != 2 || getter.revisions[0] != 0 || getter.revisions[1] != 10 { + t.Fatalf("range revisions = %v, want [0 10]", getter.revisions) + } + if revision := result.(map[string]any)["revision"]; revision != "10" { + t.Fatalf("result revision = %v, want 10", revision) + } +} + func TestHistoryStartRevision(t *testing.T) { cases := []struct { requested *int64 @@ -469,6 +590,62 @@ func TestPutRejectsConflictingLeaseOptions(t *testing.T) { } } +func TestNormalizeReadRangesKeepsOnlyOutermostReadableScopes(t *testing.T) { + ranges := normalizeReadRanges([]etcdReadRange{ + {start: "/team-a/", end: "/team-a0"}, + {start: "/team-a/config", end: "/team-a/config\x00"}, + {start: "/team-b/", end: "/team-b0"}, + {start: "/invalid", end: "/invalid"}, + }) + if len(ranges) != 2 { + t.Fatalf("readable ranges = %#v, want two outer ranges", ranges) + } + if ranges[0].start != "/team-a/" || ranges[1].start != "/team-b/" { + t.Fatalf("unexpected readable ranges: %#v", ranges) + } + merged := normalizeReadRanges([]etcdReadRange{{start: "a", end: "d"}, {start: "c", end: "f"}}) + if len(merged) != 1 || merged[0].start != "a" || merged[0].end != "f" { + t.Fatalf("overlapping ranges must merge: %#v", merged) + } + fromKey := normalizeReadRanges([]etcdReadRange{{start: "/team-c/", end: unboundedRangeEnd}, {start: "/team-d/", end: "/team-e/"}}) + if len(fromKey) != 1 || fromKey[0].start != "/team-c/" || fromKey[0].end != unboundedRangeEnd { + t.Fatalf("from-key permission must remain unbounded: %#v", fromKey) + } + if rangeStartAtOrAfterEnd("/team-z/", unboundedRangeEnd) { + t.Fatal("an unbounded range end must not exclude a later continuation") + } + if !isAllKeyPermission("", "\x00") || !isAllKeyPermission("\x00", "\x00") { + t.Fatal("all-Key permissions must be recognized") + } +} + +func TestIntersectReadRangesLimitsBroadPrefixToGrantedScopes(t *testing.T) { + granted := []etcdReadRange{ + {start: "/dbx", end: "/dby"}, + {start: "/team/secret", end: "/team/secret\x00"}, + } + + broad := intersectReadRanges(granted, "/", "0") + if len(broad) != 2 || broad[0] != granted[0] || broad[1] != granted[1] { + t.Fatalf("broad prefix intersections = %#v, want %#v", broad, granted) + } + + narrow := intersectReadRanges(granted, "/dbx/config/", "/dbx/config0") + if len(narrow) != 1 || narrow[0].start != "/dbx/config/" || narrow[0].end != "/dbx/config0" { + t.Fatalf("narrow prefix intersection = %#v", narrow) + } + + disjoint := intersectReadRanges(granted, "/other/", "/other0") + if len(disjoint) != 0 { + t.Fatalf("disjoint prefix intersections = %#v, want none", disjoint) + } + + unbounded := intersectReadRanges([]etcdReadRange{{start: "/dbx", end: unboundedRangeEnd}}, "/dbx", unboundedRangeEnd) + if len(unbounded) != 1 || unbounded[0].end != unboundedRangeEnd { + t.Fatalf("unbounded prefix intersection = %#v", unbounded) + } +} + func (s *etcdSession) watchBufferedBytesSnapshot() int64 { s.watchesMu.Lock() defer s.watchesMu.Unlock() diff --git a/agents/drivers/etcd-go/status.go b/agents/drivers/etcd-go/status.go index 95f7d22726..0634f68286 100644 --- a/agents/drivers/etcd-go/status.go +++ b/agents/drivers/etcd-go/status.go @@ -128,6 +128,11 @@ func (s *etcdSession) status(params map[string]json.RawMessage) (any, error) { row["learner"] = memberStatus.IsLearner row["reachable"] = true row["latencyMs"] = time.Since(result.started).Milliseconds() + // KvStatusMember.errors is a required field in the desktop protocol. + // Successful probes must therefore return an explicit empty list rather + // than omitting the field, otherwise the host cannot deserialize the + // complete status response. + row["errors"] = []string{} memberIDKey := unsignedLongString(int64(memberID)) if _, seen := observedMemberIDs[memberIDKey]; seen { continue diff --git a/agents/drivers/etcd-go/watch.go b/agents/drivers/etcd-go/watch.go index 19af8d97d6..9b8283a77e 100644 --- a/agents/drivers/etcd-go/watch.go +++ b/agents/drivers/etcd-go/watch.go @@ -236,7 +236,14 @@ func (s *etcdSession) watchStart(params map[string]json.RawMessage) (any, error) startedRevision = *requestedRevision } else { ctx, cancel := context.WithTimeout(context.Background(), rpcTimeoutSeconds*time.Second) - response, err := client.Get(ctx, "\x00", clientv3.WithRange("\x00"), clientv3.WithCountOnly()) + // Read the revision from the same Key scope that will be watched. A global + // range is forbidden for users that are intentionally limited to one or + // more prefixes, even when this individual Key or prefix is readable. + revisionOptions := []clientv3.OpOption{clientv3.WithCountOnly()} + if scope == "prefix" { + revisionOptions = append(revisionOptions, clientv3.WithRange(prefixEnd(key))) + } + response, err := client.Get(ctx, key, revisionOptions...) cancel() if err != nil { return nil, err diff --git a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue index 9ceb1bda3c..50dfe99004 100644 --- a/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue +++ b/apps/desktop/src/components/etcd/EtcdKeyBrowser.vue @@ -154,11 +154,25 @@ const transferLoadingDetail = ref(""); const transferPreviewLoaded = ref(false); const syncConfigurationExpanded = ref(true); -const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId))); +const etcdAccess = computed(() => connectionStore.getEtcdAccessCapabilities(props.connectionId)); +const canManageEtcd = computed(() => etcdAccess.value.admin); +const canWriteEtcdKeys = computed(() => etcdAccess.value.writable); +const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)) || !canWriteEtcdKeys.value); +function canWriteConnectionKey(connectionId: string, key: string, keyBytes?: api.KvValue | null): boolean { + const config = connectionStore.getConfig(connectionId); + return config?.db_type === "etcd" && !connectionIsEffectivelyReadOnly(config) && connectionStore.canWriteEtcdKey(connectionId, key, keyBytes); +} +function canWriteCurrentKey(route: { key: string; keyBytes?: api.KvValue | null }): boolean { + return canWriteConnectionKey(props.connectionId, route.key, route.keyBytes); +} +function permissionDenied(): Error { + return new Error("ETCD_PERMISSION_DENIED: The current etcd user is not authorized to write this Key"); +} // etcd 2.x connections speak the v2 API: no key history, no leases, no maintenance. const isV2Api = computed(() => connectionStore.getConfig(props.connectionId)?.driver_profile === "etcd-v2"); const etcdConnections = computed(() => connectionStore.connections.filter((connection) => connection.db_type === "etcd")); -const targetReadOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value))); +const targetReadOnly = computed(() => !targetConnectionId.value || connectionIsEffectivelyReadOnly(connectionStore.getConfig(targetConnectionId.value)) || !connectionStore.getEtcdAccessCapabilities(targetConnectionId.value).writable); +const selectedTreeKeysWritable = computed(() => selectedTreeKeys.value.length > 0 && selectedTreeKeys.value.every((item) => canWriteCurrentKey(item))); const selectedTransferRows = computed(() => transferRows.value.filter((row) => row.selected && isTransferRowSelectable(row))); const selectableTransferRows = computed(() => transferRows.value.filter(isTransferRowSelectable)); const filteredTransferRows = computed(() => { @@ -274,13 +288,23 @@ const etcdApi = computed(() => ({ return { ...result, key: shown, keyIdentity: identity }; }, getMetadata: (connectionId: string, key: string, options?: api.KvGetOptions | null) => api.etcdGet(connectionId, key, { ...keyOptions(key), ...options, metadataOnly: true }), - put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) => - api.etcdPut(connectionId, key, value, { + put: (connectionId: string, key: string, value: api.KvValue, options?: api.KvPutOptions | null) => { + const keyBytes = options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes); + if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied(); + return api.etcdPut(connectionId, key, value, { ...options, - keyBytes: options?.keyBytes ?? (options?.expectedCreateRevision === "0" ? undefined : keyOptions(key).keyBytes), - }), - deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => api.etcdDelete(connectionId, key, { ...options, keyBytes: options?.keyBytes ?? keyOptions(key).keyBytes }), - rename: api.etcdRename, + keyBytes, + }); + }, + deleteKey: (connectionId: string, key: string, options?: api.KvDeleteOptions | null) => { + const keyBytes = options?.keyBytes ?? keyOptions(key).keyBytes; + if (!canWriteConnectionKey(connectionId, key, keyBytes)) throw permissionDenied(); + return api.etcdDelete(connectionId, key, { ...options, keyBytes }); + }, + rename: (connectionId: string, request: { key: string; keyBytes?: api.KvValue | null; newKey: string; expectedModRevision?: api.KvInt64 | null }) => { + if (!canWriteConnectionKey(connectionId, request.key, request.keyBytes) || !canWriteConnectionKey(connectionId, request.newKey)) throw permissionDenied(); + return api.etcdRename(connectionId, request); + }, // The v2 API has no event history; hiding the entry keeps KvKeyBrowser from // offering restore/compare actions it cannot fulfill. history: isV2Api.value ? undefined : api.etcdHistory, @@ -344,6 +368,11 @@ const labels = computed(() => ({ })); async function refreshTtlCapability() { + if (!canManageEtcd.value) { + supportsTtl.value = false; + ttlCapabilityKnown.value = true; + return; + } const connectionId = props.connectionId; if (ttlCapabilityInFlightConnection === connectionId) return; const request = ++ttlCapabilityRequest; @@ -373,8 +402,16 @@ function stopTtlCapabilityRefresh() { function startTtlCapabilityRefresh() { stopTtlCapabilityRefresh(); - void refreshTtlCapability(); - ttlCapabilityRefreshTimer = setInterval(() => void refreshTtlCapability(), ttlCapabilityRefreshIntervalMs); + const refreshAccessAndTtl = async () => { + try { + await connectionStore.ensureEtcdAccessCapabilities(props.connectionId, { force: true, verifyHealth: false }); + } catch { + // The Key browser surfaces connection failures through its own requests. + } + await refreshTtlCapability(); + }; + void refreshAccessAndTtl(); + ttlCapabilityRefreshTimer = setInterval(() => void refreshAccessAndTtl(), ttlCapabilityRefreshIntervalMs); } watch( @@ -400,6 +437,7 @@ function normalizedLease(metadata?: api.KvKeyMetadata | null) { function isTransferRowSelectable(row: TransferRow): boolean { if (["unchanged", "skipped", "applied"].includes(row.operation)) return false; + if (!row.source || !canWriteConnectionKey(targetConnectionId.value, row.displayKey, row.source.key)) return false; if (transferMode.value !== "sync" || row.operation !== "update") return true; return transferConflictPolicy.value === "OVERWRITE"; } @@ -533,7 +571,7 @@ function selectedTreeKeyDetails(): string { async function deleteSelectedTreeKeys() { const selected = [...selectedTreeKeys.value]; - if (!selected.length || readOnly.value) return; + if (!selected.length || readOnly.value || !selected.every((item) => canWriteCurrentKey(item))) return; batchDeleting.value = true; const completed: EtcdMultiSelection[] = []; let deleted = 0; @@ -708,6 +746,7 @@ async function onImportFile(event: Event) { } async function openSync() { + if (readOnly.value) return; transferMode.value = "sync"; targetConnectionId.value = etcdConnections.value.find((connection) => connection.id !== props.connectionId)?.id || ""; transferKeyFilter.value = ""; @@ -768,12 +807,17 @@ async function previewTransfer() { transferError.value = ""; transferPreviewLoaded.value = false; try { + await connectionStore.ensureEtcdAccessCapabilities(targetId, { force: true, verifyHealth: false }); + if (generation !== transferPreviewGeneration) return; let compared = 0; const sourceRows = await mapWithConcurrency(bundle.entries, TARGET_LOOKUP_CONCURRENCY, async (source) => { const shown = displayKey(source.key); if (normalizedLease(source.metadata) !== "0") { return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: "Leased keys are skipped by default.", selected: false }; } + if (!canWriteConnectionKey(targetId, shown, source.key)) { + return { id: `source:${kvValueByteIdentity(source.key)}`, displayKey: shown, source, operation: "skipped" as const, reason: t("etcd.targetKeyWriteDenied"), selected: false }; + } const target = await api.etcdGet(targetId, shown, { keyBytes: source.key }); if (generation !== transferPreviewGeneration) throw new Error("同步预览已被新的请求替换。"); compared++; @@ -809,6 +853,10 @@ async function applyTransfer() { const targetId = targetConnectionId.value; const rows = [...selectedTransferRows.value]; if (!targetId || rows.length === 0) return; + if (!rows.every((row) => row.source && canWriteConnectionKey(targetId, row.displayKey, row.source.key))) { + transferError.value = t("etcd.selectedKeysWriteDenied"); + return; + } // Invalidate any preview that is still resolving before writes begin. transferPreviewGeneration++; transferApplying.value = true; @@ -966,8 +1014,16 @@ async function openSearchResult(result: SearchResult) { } async function openOperations(nextMode: Extract) { + if ((nextMode === "maintenance" || nextMode === "lease") && !canManageEtcd.value) return; activeOperation.value = nextMode; mode.value = nextMode; + operationsStatus.value = null; + // A watch is scoped to the Key/prefix supplied by the user. Do not probe + // cluster status first: ordinary etcd users normally lack that privilege. + if (nextMode === "watch") { + operationsLoading.value = false; + return; + } operationsLoading.value = true; try { operationsStatus.value = await api.etcdStatus(props.connectionId); @@ -988,6 +1044,7 @@ function openWatchWorkspaceAfterCreate() { } async function refreshLeaseOptions() { + if (!canManageEtcd.value) return; try { leaseOptions.value = (await api.etcdLeaseList(props.connectionId)).leases; } catch (error) { @@ -1034,7 +1091,7 @@ defineExpose({ focusSearch, refresh }); - + - +
{{ t("connection.readOnly") }} @@ -1064,9 +1121,9 @@ defineExpose({ focusSearch, refresh }); - + - +
@@ -1077,13 +1134,14 @@ defineExpose({ focusSearch, refresh }); :connection-id="props.connectionId" :api="etcdApi" :labels="labels" - :supports-ttl="supportsTtl" - :supports-lease-binding="!isV2Api" + :supports-ttl="supportsTtl && canManageEtcd" + :supports-lease-binding="!isV2Api && canManageEtcd" :ttl-capability-known="ttlCapabilityKnown" :enable-node-actions="true" :safe-write="true" :allow-binary-edit="true" :read-only="readOnly" + :can-write-key="canWriteCurrentKey" :enable-multi-select="true" :on-watch-key="openWatchForKey" export-format="dbx-etcd-bundle" @@ -1164,10 +1222,10 @@ defineExpose({ focusSearch, refresh });
输入关键词后开始搜索。可使用 Prefix 限定扫描范围。
-
+
加载集群状态...
({ etcdDelete: vi.fn(), etcdRename: vi.fn(), etcdHistory: vi.fn(), + etcdStatus: vi.fn(), etcdGetCalls: [] as unknown[][], capturedKvBrowserApi: null as any, + capturedCanWriteKey: null as any, + canWriteEtcdKey: vi.fn(), + ensureEtcdAccessCapabilities: vi.fn(), selectKeyCalls: [] as unknown[], + access: { admin: true, writable: true }, })); vi.mock("vue-i18n", () => ({ @@ -27,13 +32,17 @@ vi.mock("@/lib/backend/api", () => ({ etcdDelete: backend.etcdDelete, etcdRename: backend.etcdRename, etcdHistory: backend.etcdHistory, + etcdStatus: backend.etcdStatus, etcdSupportsTtl: backend.etcdSupportsTtl, })); vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => ({ connections: [], - getConfig: () => undefined, + getConfig: () => ({ id: "etcd-1", db_type: "etcd" }), + getEtcdAccessCapabilities: () => backend.access, + ensureEtcdAccessCapabilities: backend.ensureEtcdAccessCapabilities, + canWriteEtcdKey: backend.canWriteEtcdKey, }), })); @@ -45,11 +54,15 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({ api: { type: Object, required: true }, connectionId: { type: String, required: true }, supportsTtl: { type: Boolean, default: false }, + supportsLeaseBinding: { type: Boolean, default: false }, ttlCapabilityKnown: { type: Boolean, default: true }, + readOnly: { type: Boolean, default: false }, + canWriteKey: { type: Function, default: undefined }, }, emits: ["refreshRequested", "selectionChange"], setup(props, { emit, expose }) { backend.capturedKvBrowserApi = props.api; + backend.capturedCanWriteKey = props.canWriteKey; expose({ focusSearch: () => true, selectKey: (route: unknown) => { @@ -73,7 +86,9 @@ vi.mock("@/components/kv/KvKeyBrowser.vue", () => ({ { id: "kv-browser-stub", "data-supports-ttl": String(props.supportsTtl), + "data-supports-lease-binding": String(props.supportsLeaseBinding), "data-capability-known": String(props.ttlCapabilityKnown), + "data-read-only": String(props.readOnly), onClick: () => emit("refreshRequested"), }, "refresh", @@ -112,6 +127,15 @@ vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ }), })); +vi.mock("@/components/etcd/EtcdAdminConsole.vue", () => ({ + default: defineComponent({ + name: "EtcdAdminConsoleStub", + setup() { + return () => h("div", { id: "etcd-admin-console-stub" }); + }, + }), +})); + vi.mock("@/components/ui/dropdown-menu", () => ({ DropdownMenu: defineComponent({ name: "DropdownMenuStub", @@ -175,10 +199,16 @@ beforeEach(() => { backend.etcdDelete.mockReset(); backend.etcdRename.mockReset(); backend.etcdHistory.mockReset(); + backend.etcdStatus.mockReset(); backend.etcdListPrefix.mockResolvedValue({ keys: [], continuation: null, revision: "1" }); backend.etcdGetCalls.length = 0; backend.capturedKvBrowserApi = null; + backend.capturedCanWriteKey = null; + backend.canWriteEtcdKey.mockReset().mockReturnValue(true); + backend.ensureEtcdAccessCapabilities.mockReset().mockImplementation(async () => backend.access); backend.selectKeyCalls.length = 0; + backend.access.admin = true; + backend.access.writable = true; }); afterEach(() => { @@ -236,6 +266,37 @@ describe("EtcdKeyBrowser TTL capability recovery", () => { }); }); +describe("EtcdKeyBrowser restricted etcd access", () => { + it("loads access capabilities when a restored tab mounts without sidebar expansion", async () => { + await mountBrowser(); + + await vi.waitFor(() => + expect(backend.ensureEtcdAccessCapabilities).toHaveBeenCalledWith("etcd-1", { + force: true, + verifyHealth: false, + }), + ); + }); + + it("hides cluster controls and opens a Key watch without reading cluster status", async () => { + backend.access.admin = false; + backend.access.writable = false; + + const browser = await mountBrowser(); + expect(browser.dataset.readOnly).toBe("true"); + expect(browser.dataset.supportsLeaseBinding).toBe("false"); + expect(root!.textContent).not.toContain("etcd.admin.maintenance"); + expect(root!.textContent).not.toContain("etcd.admin.lease"); + + const watch = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.admin.watch")); + watch?.click(); + await flushUi(); + + expect(root!.querySelector("#etcd-admin-console-stub")).toBeTruthy(); + expect(backend.etcdStatus).not.toHaveBeenCalled(); + }); +}); + describe("EtcdKeyBrowser global search", () => { it("clears results and counters while retaining the search form", async () => { backend.etcdListPrefix.mockResolvedValue({ @@ -456,4 +517,18 @@ describe("EtcdKeyBrowser tree batch operations", () => { const selectedExports = [...root!.querySelectorAll("button")].filter((button) => button.textContent?.includes("etcd.exportSelection")); expect(selectedExports).toHaveLength(0); }); + + it("blocks a selected Key outside the user's write ranges before any delete", async () => { + backend.canWriteEtcdKey.mockImplementation((_connectionId, _key, keyBytes) => keyBytes?.data !== "/w=="); + + await mountBrowser(); + root!.querySelector("#emit-tree-selection")!.click(); + await flushUi(); + + const batchDelete = [...root!.querySelectorAll("button")].find((button) => button.textContent?.includes("etcd.delete")) as HTMLButtonElement; + expect(batchDelete.disabled).toBe(true); + expect(() => backend.capturedKvBrowserApi.deleteKey("etcd-1", "[base64:/w==]", { keyBytes: { encoding: "base64", data: "/w==" } })).toThrow("ETCD_PERMISSION_DENIED"); + expect(backend.etcdDelete).not.toHaveBeenCalled(); + expect(backend.capturedCanWriteKey({ key: "[base64:/w==]", keyBytes: { encoding: "base64", data: "/w==" } })).toBe(false); + }); }); diff --git a/apps/desktop/src/components/kv/KvKeyBrowser.vue b/apps/desktop/src/components/kv/KvKeyBrowser.vue index fb20fd8a44..4010b1364a 100644 --- a/apps/desktop/src/components/kv/KvKeyBrowser.vue +++ b/apps/desktop/src/components/kv/KvKeyBrowser.vue @@ -203,6 +203,7 @@ const props = withDefaults( exportFileExtension?: string; exportFallbackName?: string; enableMultiSelect?: boolean; + canWriteKey?: (route: KvKeyRoute) => boolean; onWatchKey?: (route: KvKeyRoute) => void; onDeletePrefix?: (prefix: string) => void; watchActiveKey?: string | null; @@ -413,6 +414,21 @@ const selectedValueClipboardText = computed(() => { return selectedValueUsesUtf8Preview.value && preview?.ok ? preview.value : selectedTextValue.value; }); const selectedKeyBytes = computed(() => selectedValue.value?.keyBytes ?? selectedRouteKeyBytes.value ?? null); +function keyIsWritable(key: string, keyBytes?: KvValue | null): boolean { + return !props.readOnly && (props.canWriteKey?.({ key, keyBytes }) ?? true); +} +const selectedKeyWritable = computed(() => Boolean(selectedKey.value && keyIsWritable(selectedKey.value, selectedKeyBytes.value))); +const editKeyWritable = computed(() => { + const key = editKey.value.trim(); + if (!key) return false; + const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null; + return keyIsWritable(key, keyBytes); +}); +const renameTargetWritable = computed(() => { + const target = renameValue.value.trim(); + if (!target) return false; + return keyIsWritable(target) && (renameMode.value === "copy" || selectedKeyWritable.value); +}); const selectedKeyLocked = computed(() => Boolean(String(selectedMetadata.value?.session ?? "").trim())); const isWatchingSelectedKey = computed(() => Boolean(selectedKey.value && props.watchActiveKey === selectedKey.value)); const activeSearchHighlight = computed(() => (props.searchHighlight?.key === selectedKey.value ? props.searchHighlight : null)); @@ -460,8 +476,8 @@ const editValueSize = computed(() => { } return new TextEncoder().encode(editValue.value).length; }); -const canEditSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit)); -const canDeleteSelectedValue = computed(() => !props.readOnly && !selectedKeyLocked.value); +const canEditSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value && (!selectedValueIsBase64.value || props.allowBinaryEdit)); +const canDeleteSelectedValue = computed(() => selectedKeyWritable.value && !selectedKeyLocked.value); const consulMetadataRows = computed(() => { const metadata = selectedMetadata.value; return [ @@ -1258,6 +1274,11 @@ async function saveKey() { return; } const key = props.lazyHierarchy ? normalizeLazyKvPath(rawKey, props.lazyPathStyle) : rawKey; + const keyBytes = !isCreating.value && key === selectedKey.value ? selectedKeyBytes.value : null; + if (!keyIsWritable(key, keyBytes)) { + editError.value = t("connection.readOnly"); + return; + } const validationError = validateKvValue(editValue.value, editFormat.value); if (validationError) { editError.value = validationError; @@ -1275,6 +1296,7 @@ async function saveKey() { async function confirmSaveKey() { if (!pendingSave.value) return; const { key, value, options } = pendingSave.value; + if (!keyIsWritable(key, options?.keyBytes)) return; saving.value = true; editError.value = ""; editErrorKind.value = "request"; @@ -1367,7 +1389,8 @@ async function selectNodeForAction(node: BrowserTreeNode) { } async function openDeleteForNode(node: BrowserTreeNode) { - if (props.readOnly) return; + const route = routeFromNode(node); + if (!keyIsWritable(route.key, route.keyBytes)) return; await selectNodeForAction(node); if (!selectedKey.value || !selectedValue.value?.found || !canDeleteSelectedValue.value) return; showDeleteConfirm.value = true; @@ -1499,7 +1522,7 @@ function onEditFormatChange(value: unknown) { } function openRenameDialog() { - if (!selectedKey.value || !props.api.rename || props.readOnly) return; + if (!selectedKey.value || !props.api.rename || !selectedKeyWritable.value) return; renameMode.value = "rename"; renameValue.value = selectedKey.value; renameError.value = ""; @@ -1515,6 +1538,10 @@ async function moveOrCopySelectedKey() { renameError.value = props.labels.keyRequired; return; } + if (!renameTargetWritable.value) { + renameError.value = t("connection.readOnly"); + return; + } renaming.value = true; renameError.value = ""; try { @@ -1564,7 +1591,7 @@ function compareHistory(event: KvHistoryEvent) { } async function restoreHistory() { - if (!selectedKey.value || !historyRestoreValue.value) return; + if (!selectedKey.value || !historyRestoreValue.value || !selectedKeyWritable.value) return; restoring.value = true; try { await props.api.put(props.connectionId, selectedKey.value, historyRestoreValue.value, { @@ -1584,6 +1611,8 @@ async function restoreHistory() { function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] { if (!props.enableNodeActions) return []; + const route = routeFromNode(node); + const nodeWritable = keyIsWritable(route.key, route.keyBytes); const items: ContextMenuItem[] = [ { label: props.labels.add || props.labels.newKey, @@ -1598,7 +1627,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] { label: props.labels.edit, icon: Pencil, action: () => void loadSelectedKey(routeFromNode(node)).then(openEditDialog), - disabled: props.readOnly, + disabled: !nodeWritable, }, { label: props.labels.clone || "Clone", @@ -1617,7 +1646,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] { label: props.labels.rename || "Rename", icon: Pencil, action: () => void loadSelectedKey(routeFromNode(node)).then(openRenameDialog), - disabled: props.readOnly, + disabled: !nodeWritable, }); } if (props.api.history) { @@ -1649,7 +1678,7 @@ function nodeContextMenuItems(node: BrowserTreeNode): ContextMenuItem[] { icon: Trash2, variant: "destructive", action: () => void openDeleteForNode(node), - disabled: props.readOnly || !nodeHasValue(node), + disabled: !nodeWritable || !nodeHasValue(node), }); } return items; @@ -2239,7 +2268,7 @@ defineExpose({
- @@ -2260,7 +2289,7 @@ defineExpose({
- @@ -2317,7 +2346,7 @@ defineExpose({ :before="selectedTextValue" :after="historyRestoreValue?.data || ''" :loading="restoring" - :show-confirm="!readOnly && !!historyRestoreValue" + :show-confirm="selectedKeyWritable && !!historyRestoreValue" :confirm-label="labels.restore || 'Restore'" @confirm="restoreHistory" /> diff --git a/apps/desktop/src/components/kv/__tests__/KvKeyBrowserLayout.spec.ts b/apps/desktop/src/components/kv/__tests__/KvKeyBrowserLayout.spec.ts index fd4ae4dffc..5ba46c14c5 100644 --- a/apps/desktop/src/components/kv/__tests__/KvKeyBrowserLayout.spec.ts +++ b/apps/desktop/src/components/kv/__tests__/KvKeyBrowserLayout.spec.ts @@ -14,7 +14,7 @@ describe("KvKeyBrowser edit dialog layout", () => { it("cancels the shared footer negative margin and preserves bottom safe spacing", () => { expect(browserSource).toContain(''); expect(browserSource).toContain('variant="outline" class="h-10 min-w-20"'); - expect(browserSource).toContain('