From f50c46f8454cdbc674bd40b674a153768ed8b83a Mon Sep 17 00:00:00 2001 From: Taohao Wang Date: Wed, 27 May 2026 16:26:30 -0400 Subject: [PATCH 1/5] Support Global Key - Store & Use --- internal/api-key/command_create.go | 29 +++- internal/api-key/command_store.go | 70 +++++++-- internal/api-key/command_use.go | 32 +++- internal/kafka/command_clientconfig_create.go | 13 +- internal/kafka/command_topic.go | 8 +- internal/kafka/command_topic_consume.go | 4 +- internal/kafka/command_topic_produce.go | 4 +- internal/kafka/confluent_kafka.go | 8 +- internal/kafka/confluent_kafka_configs.go | 19 ++- pkg/config/config_test.go | 5 +- pkg/config/context.go | 145 ++++++++++++++++++ pkg/config/context_test.go | 91 +++++++++++ pkg/keystore/keystore.go | 18 ++- 13 files changed, 394 insertions(+), 52 deletions(-) diff --git a/internal/api-key/command_create.go b/internal/api-key/command_create.go index 2c1ea032f8..8d5788be2f 100644 --- a/internal/api-key/command_create.go +++ b/internal/api-key/command_create.go @@ -171,10 +171,17 @@ func (c *command) create(cmd *cobra.Command, _ []string) error { return err } - if resourceType == resource.KafkaCluster { + switch resourceType { + case resource.KafkaCluster: if err := c.keystore.StoreAPIKey(c.V2Client, userKey, resourceId); err != nil { return fmt.Errorf(unableToStoreApiKeyErrorMsg, err) } + case resource.Global: + // Global keys' secrets are irretrievable after creation, so always store them locally — same + // rationale as Kafka cluster keys. The user can still copy the printed secret if they want. + if err := c.keystore.StoreGlobalAPIKey(userKey); err != nil { + return fmt.Errorf(unableToStoreApiKeyErrorMsg, err) + } } use, err := cmd.Flags().GetBool("use") @@ -182,13 +189,23 @@ func (c *command) create(cmd *cobra.Command, _ []string) error { return err } if use { - if resourceType != resource.KafkaCluster { + switch resourceType { + case resource.KafkaCluster: + if err := c.useAPIKey(userKey.Key, resourceId); err != nil { + return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, userKey.Key)) + } + output.Printf(c.Config.EnableColor, useAPIKeyMsg, userKey.Key) + case resource.Global: + if err := c.Context.SetActiveGlobalAPIKey(userKey.Key); err != nil { + return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, userKey.Key)) + } + if err := c.Config.Save(); err != nil { + return err + } + output.Printf(c.Config.EnableColor, useGlobalAPIKeyMsg, userKey.Key) + default: return fmt.Errorf("`--use` set but ineffective: %s", nonKafkaNotImplementedErrorMsg) } - if err := c.useAPIKey(userKey.Key, resourceId); err != nil { - return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, userKey.Key)) - } - output.Printf(c.Config.EnableColor, useAPIKeyMsg, userKey.Key) } return nil diff --git a/internal/api-key/command_store.go b/internal/api-key/command_store.go index a7977be2eb..c93d393679 100644 --- a/internal/api-key/command_store.go +++ b/internal/api-key/command_store.go @@ -50,12 +50,48 @@ func (c *command) newStoreCommand() *cobra.Command { func (c *command) store(cmd *cobra.Command, args []string) error { c.setKeyStoreIfNil() + key := args[0] + secret := args[1] + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return err + } + + // Check if API key exists server-side + apiKey, httpResp, err := c.V2Client.GetApiKey(key) + if err != nil { + return errors.CatchApiKeyForbiddenAccessError(err, getOperation, httpResp) + } + + resourceType, clusterId, _, resolveErr := c.resolveResourceId(cmd, c.V2Client) + isGlobalKey := apiKey.GetSpec().Resource.GetKind() == "Global" + + // Detect Global API keys by either the explicit --resource global flag or the server-side resource Kind. + // Global keys are org-scoped and stored separately from cluster-scoped keys. + if resourceType == resource.Global || isGlobalKey { + if resourceType == resource.Global && !isGlobalKey { + return errors.NewErrorWithSuggestions( + fmt.Sprintf("API key %q is not a Global API key", key), + "Omit `--resource global`, or pass the correct resource ID.", + ) + } + if isGlobalKey && resourceType != "" && resourceType != resource.Global { + return errors.NewErrorWithSuggestions( + fmt.Sprintf("API key %q is a Global API key, but --resource was set to %q", key, resourceType), + "Re-run with `--resource global`, or omit `--resource` to auto-detect.", + ) + } + return c.storeGlobal(key, secret, force) + } + var cluster *config.KafkaClusterConfig // Attempt to get cluster from --resource flag if set; if that doesn't work, - // attempt to fall back to the currently active Kafka cluster - resourceType, clusterId, _, err := c.resolveResourceId(cmd, c.V2Client) - if err == nil && clusterId != "" { + // attempt to fall back to the currently active Kafka cluster. + // Preserve historical behavior: if --resource resolution failed, silently fall through to the + // active cluster (the cluster/key-mismatch check below will surface real problems). + if resolveErr == nil && clusterId != "" { if resourceType != resource.KafkaCluster { return errors.New(nonKafkaNotImplementedErrorMsg) } @@ -74,20 +110,6 @@ func (c *command) store(cmd *cobra.Command, args []string) error { } } - key := args[0] - secret := args[1] - - force, err := cmd.Flags().GetBool("force") - if err != nil { - return err - } - - // Check if API key exists server-side - apiKey, httpResp, err := c.V2Client.GetApiKey(key) - if err != nil { - return errors.CatchApiKeyForbiddenAccessError(err, getOperation, httpResp) - } - apiKeyIsValidForTargetCluster := cluster.GetId() != "" && cluster.GetId() == apiKey.GetSpec().Resource.GetId() if !apiKeyIsValidForTargetCluster { @@ -114,3 +136,17 @@ func (c *command) store(cmd *cobra.Command, args []string) error { output.ErrPrintf(c.Config.EnableColor, "Stored secret for API key \"%s\".\n", key) return nil } + +func (c *command) storeGlobal(key, secret string, force bool) error { + if c.keystore.HasGlobalAPIKey(key) && !force { + return errors.NewErrorWithSuggestions( + fmt.Sprintf(`refusing to overwrite existing secret for API Key "%s"`, key), + fmt.Sprintf(refuseToOverrideSecretSuggestions, key), + ) + } + if err := c.keystore.StoreGlobalAPIKey(&config.APIKeyPair{Key: key, Secret: secret}); err != nil { + return fmt.Errorf(unableToStoreApiKeyErrorMsg, err) + } + output.ErrPrintf(c.Config.EnableColor, "Stored secret for Global API key \"%s\".\n", key) + return nil +} diff --git a/internal/api-key/command_use.go b/internal/api-key/command_use.go index 40ef7d5473..e433baa762 100644 --- a/internal/api-key/command_use.go +++ b/internal/api-key/command_use.go @@ -12,7 +12,10 @@ import ( "github.com/confluentinc/cli/v4/pkg/resource" ) -const useAPIKeyMsg = "Using API Key \"%s\".\n" +const ( + useAPIKeyMsg = "Using API Key \"%s\".\n" + useGlobalAPIKeyMsg = "Using Global API Key \"%s\".\n" +) func (c *command) newUseCommand() *cobra.Command { cmd := &cobra.Command{ @@ -34,6 +37,21 @@ func (c *command) newUseCommand() *cobra.Command { func (c *command) use(cmd *cobra.Command, args []string) error { c.setKeyStoreIfNil() + apiKey := args[0] + + // Global keys are stored on the Context, not on a specific Kafka cluster. Check there first so + // `confluent api-key use ` without --resource works seamlessly. + if !cmd.Flags().Changed("resource") && c.Context.HasGlobalAPIKey(apiKey) { + if err := c.Context.SetActiveGlobalAPIKey(apiKey); err != nil { + return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, apiKey)) + } + if err := c.Config.Save(); err != nil { + return err + } + output.Printf(c.Config.EnableColor, useGlobalAPIKeyMsg, apiKey) + return nil + } + var clusterId string if cmd.Flags().Changed("resource") { @@ -46,20 +64,20 @@ func (c *command) use(cmd *cobra.Command, args []string) error { } clusterId = resourceId } else { - clusterId = c.Context.KafkaClusterContext.FindApiKeyClusterId(args[0]) + clusterId = c.Context.KafkaClusterContext.FindApiKeyClusterId(apiKey) if clusterId == "" { return errors.NewErrorWithSuggestions( - fmt.Sprintf(`API key "%s" and associated Kafka cluster are not stored in local CLI state`, args[0]), - fmt.Sprintf(apiKeyUseFailedSuggestions, args[0]), + fmt.Sprintf(`API key "%s" and associated Kafka cluster are not stored in local CLI state`, apiKey), + fmt.Sprintf(apiKeyUseFailedSuggestions, apiKey), ) } } - if err := c.useAPIKey(args[0], clusterId); err != nil { - return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, args[0])) + if err := c.useAPIKey(apiKey, clusterId); err != nil { + return errors.NewWrapErrorWithSuggestions(err, apiKeyUseFailedErrorMsg, fmt.Sprintf(apiKeyUseFailedSuggestions, apiKey)) } - output.Printf(c.Config.EnableColor, useAPIKeyMsg, args[0]) + output.Printf(c.Config.EnableColor, useAPIKeyMsg, apiKey) return nil } diff --git a/internal/kafka/command_clientconfig_create.go b/internal/kafka/command_clientconfig_create.go index 7c8ace0762..34e5a381ce 100644 --- a/internal/kafka/command_clientconfig_create.go +++ b/internal/kafka/command_clientconfig_create.go @@ -179,7 +179,7 @@ func (c *clientConfigCommand) setKafkaCluster(cmd *cobra.Command, configFile str return "", err } - if err := addApiKeyToCluster(cmd, kafkaCluster); err != nil { + if err := addApiKeyToCluster(cmd, c.Context, kafkaCluster); err != nil { return "", err } @@ -200,11 +200,16 @@ func (c *clientConfigCommand) setKafkaCluster(cmd *cobra.Command, configFile str } } + apiKey, apiSecret, err := c.Context.ResolveKafkaAPIKey(kafkaCluster) + if err != nil { + return "", err + } + // replace BROKER_ENDPOINT, CLUSTER_API_KEY, and CLUSTER_API_SECRET templates configFile = replaceTemplates(configFile, map[string]string{ brokerEndpointTemplate: kafkaCluster.Bootstrap, - clusterApiKeyTemplate: kafkaCluster.APIKey, - clusterApiSecretTemplate: kafkaCluster.GetApiSecret(), + clusterApiKeyTemplate: apiKey, + clusterApiSecretTemplate: apiSecret, }) return configFile, nil } @@ -283,7 +288,7 @@ func (c *clientConfigCommand) getSchemaRegistryCluster() (*srcmv3.SrcmV3Cluster, } func (c *clientConfigCommand) validateKafkaCredentials(kafkaCluster *config.KafkaClusterConfig) error { - configMap, err := getCommonConfig(kafkaCluster, c.clientId) + configMap, err := getCommonConfig(c.Context, kafkaCluster, c.clientId) if err != nil { return err } diff --git a/internal/kafka/command_topic.go b/internal/kafka/command_topic.go index cc676b7d9f..a81fb9ac07 100644 --- a/internal/kafka/command_topic.go +++ b/internal/kafka/command_topic.go @@ -173,7 +173,7 @@ func (c *command) prepareAnonymousContext(cmd *cobra.Command) error { return nil } -func addApiKeyToCluster(cmd *cobra.Command, cluster *config.KafkaClusterConfig) error { +func addApiKeyToCluster(cmd *cobra.Command, ctx *config.Context, cluster *config.KafkaClusterConfig) error { apiKey, err := cmd.Flags().GetString("api-key") if err != nil { return err @@ -192,6 +192,12 @@ func addApiKeyToCluster(cmd *cobra.Command, cluster *config.KafkaClusterConfig) } } + // If the cluster has no scoped API key, accept an active Global API key as a fallback. The Kafka + // REST/admin path will resolve credentials via Context.ResolveKafkaAPIKey() downstream. + if cluster.APIKey == "" && ctx.GetActiveGlobalAPIKey() != "" { + return nil + } + if cluster.APIKey == "" { return &errors.UnspecifiedAPIKeyError{ClusterID: cluster.ID} } diff --git a/internal/kafka/command_topic_consume.go b/internal/kafka/command_topic_consume.go index b80faa9b59..78aa2115dc 100644 --- a/internal/kafka/command_topic_consume.go +++ b/internal/kafka/command_topic_consume.go @@ -127,7 +127,7 @@ func (c *command) consumeCloud(cmd *cobra.Command, args []string) error { cluster.Bootstrap = bootstrap } - if err := addApiKeyToCluster(cmd, cluster); err != nil { + if err := addApiKeyToCluster(cmd, c.Context, cluster); err != nil { return err } @@ -193,7 +193,7 @@ func (c *command) consumeCloud(cmd *cobra.Command, args []string) error { } } - consumer, err := newConsumer(group, cluster, c.clientID, configFile, config) + consumer, err := newConsumer(group, c.Context, cluster, c.clientID, configFile, config) if err != nil { return fmt.Errorf(errors.FailedToCreateConsumerErrorMsg, err) } diff --git a/internal/kafka/command_topic_produce.go b/internal/kafka/command_topic_produce.go index ec9c15ea98..b925d30011 100644 --- a/internal/kafka/command_topic_produce.go +++ b/internal/kafka/command_topic_produce.go @@ -143,7 +143,7 @@ func (c *command) produceCloud(cmd *cobra.Command, args []string) error { cluster.Bootstrap = bootstrap } - if err := addApiKeyToCluster(cmd, cluster); err != nil { + if err := addApiKeyToCluster(cmd, c.Context, cluster); err != nil { return err } @@ -184,7 +184,7 @@ func (c *command) produceCloud(cmd *cobra.Command, args []string) error { return err } - producer, err := newProducer(cluster, c.clientID, configFile, config) + producer, err := newProducer(c.Context, cluster, c.clientID, configFile, config) if err != nil { return fmt.Errorf(errors.FailedToCreateProducerErrorMsg, err) } diff --git a/internal/kafka/confluent_kafka.go b/internal/kafka/confluent_kafka.go index 2ebe6cfd6a..15a37bfc68 100644 --- a/internal/kafka/confluent_kafka.go +++ b/internal/kafka/confluent_kafka.go @@ -161,8 +161,8 @@ func (c *command) retrieveUnsecuredToken(e ckgo.OAuthBearerTokenRefresh) (ckgo.O return oauthBearerToken, nil } -func newProducer(kafka *config.KafkaClusterConfig, clientID, configPath string, configStrings []string) (*ckgo.Producer, error) { - configMap, err := getProducerConfigMap(kafka, clientID) +func newProducer(ctx *config.Context, kafka *config.KafkaClusterConfig, clientID, configPath string, configStrings []string) (*ckgo.Producer, error) { + configMap, err := getProducerConfigMap(ctx, kafka, clientID) if err != nil { return nil, fmt.Errorf(errors.FailedToGetConfigurationErrorMsg, err) } @@ -170,8 +170,8 @@ func newProducer(kafka *config.KafkaClusterConfig, clientID, configPath string, return newProducerWithOverwrittenConfigs(configMap, configPath, configStrings) } -func newConsumer(group string, kafka *config.KafkaClusterConfig, clientID, configPath string, configStrings []string) (*ckgo.Consumer, error) { - configMap, err := getConsumerConfigMap(group, kafka, clientID) +func newConsumer(group string, ctx *config.Context, kafka *config.KafkaClusterConfig, clientID, configPath string, configStrings []string) (*ckgo.Consumer, error) { + configMap, err := getConsumerConfigMap(group, ctx, kafka, clientID) if err != nil { return nil, fmt.Errorf(errors.FailedToGetConfigurationErrorMsg, err) } diff --git a/internal/kafka/confluent_kafka_configs.go b/internal/kafka/confluent_kafka_configs.go index 2c2f2592fb..d26198074b 100644 --- a/internal/kafka/confluent_kafka_configs.go +++ b/internal/kafka/confluent_kafka_configs.go @@ -27,26 +27,31 @@ type PartitionFilter struct { Index int32 } -func getCommonConfig(kafka *config.KafkaClusterConfig, clientId string) (*ckgo.ConfigMap, error) { +func getCommonConfig(ctx *config.Context, kafka *config.KafkaClusterConfig, clientId string) (*ckgo.ConfigMap, error) { if err := kafka.DecryptAPIKeys(); err != nil { return nil, err } + apiKey, apiSecret, err := ctx.ResolveKafkaAPIKey(kafka) + if err != nil { + return nil, err + } + configMap := &ckgo.ConfigMap{ "security.protocol": "SASL_SSL", "sasl.mechanism": "PLAIN", "ssl.endpoint.identification.algorithm": "https", "client.id": clientId, "bootstrap.servers": kafka.Bootstrap, - "sasl.username": kafka.APIKey, - "sasl.password": kafka.GetApiSecret(), + "sasl.username": apiKey, + "sasl.password": apiSecret, } return configMap, nil } -func getProducerConfigMap(kafka *config.KafkaClusterConfig, clientID string) (*ckgo.ConfigMap, error) { - configMap, err := getCommonConfig(kafka, clientID) +func getProducerConfigMap(ctx *config.Context, kafka *config.KafkaClusterConfig, clientID string) (*ckgo.ConfigMap, error) { + configMap, err := getCommonConfig(ctx, kafka, clientID) if err != nil { return nil, err } @@ -62,8 +67,8 @@ func getProducerConfigMap(kafka *config.KafkaClusterConfig, clientID string) (*c return configMap, nil } -func getConsumerConfigMap(group string, kafka *config.KafkaClusterConfig, clientID string) (*ckgo.ConfigMap, error) { - configMap, err := getCommonConfig(kafka, clientID) +func getConsumerConfigMap(group string, ctx *config.Context, kafka *config.KafkaClusterConfig, clientID string) (*ckgo.ConfigMap, error) { + configMap, err := getCommonConfig(ctx, kafka, clientID) if err != nil { return nil, err } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 720e4424d4..579d6535c2 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -119,6 +119,7 @@ func SetupTestInputs(isCloud bool) *TestInputs { CurrentEnvironment: environmentId, Environments: map[string]*EnvironmentContext{environmentId: {}}, State: regularOrgContextState, + GlobalAPIKeys: map[string]*APIKeyPair{}, } statelessContext := &Context{ Name: contextName, @@ -129,6 +130,7 @@ func SetupTestInputs(isCloud bool) *TestInputs { Environments: map[string]*EnvironmentContext{}, State: &ContextState{}, Config: &Config{SavedCredentials: savedCredentials}, + GlobalAPIKeys: map[string]*APIKeyPair{}, } twoEnvStatefulContext := &Context{ Name: contextName, @@ -141,7 +143,8 @@ func SetupTestInputs(isCloud bool) *TestInputs { "env-123456": {}, "env-flag": {}, }, - State: regularOrgContextState, + State: regularOrgContextState, + GlobalAPIKeys: map[string]*APIKeyPair{}, } context := "onprem" if isCloud { diff --git a/pkg/config/context.go b/pkg/config/context.go index 86b33235cb..fcc9f26629 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -26,6 +26,11 @@ type Context struct { LastOrgId string `json:"last_org_id,omitempty"` FeatureFlags *FeatureFlags `json:"feature_flags,omitempty"` IsMFA bool `json:"is_mfa,omitempty"` + // GlobalAPIKeys stores org-scoped (Global) API key pairs that are not tied to a single resource. + GlobalAPIKeys map[string]*APIKeyPair `json:"global_api_keys,omitempty"` + // ActiveGlobalAPIKey is the Global API key chosen via `confluent api-key use`; used as a fallback when no + // resource-scoped API key is set on the active cluster. + ActiveGlobalAPIKey string `json:"active_global_api_key,omitempty"` // Deprecated NetrcMachineName string `json:"netrc_machine_name,omitempty"` @@ -78,10 +83,55 @@ func (c *Context) validate() error { if c.State == nil { c.State = new(ContextState) } + if c.GlobalAPIKeys == nil { + c.GlobalAPIKeys = map[string]*APIKeyPair{} + } + c.validateGlobalAPIKeys() c.KafkaClusterContext.Validate() return nil } +func (c *Context) validateGlobalAPIKeys() { + missingKey := false + mismatchKey := false + missingSecret := false + for key, pair := range c.GlobalAPIKeys { + if pair == nil || pair.Key == "" { + delete(c.GlobalAPIKeys, key) + missingKey = true + continue + } + if key != pair.Key { + delete(c.GlobalAPIKeys, key) + mismatchKey = true + continue + } + if pair.Secret == "" { + delete(c.GlobalAPIKeys, key) + missingSecret = true + } + } + if missingKey || mismatchKey || missingSecret { + var problems []string + if missingKey { + problems = append(problems, "API key missing") + } + if mismatchKey { + problems = append(problems, "key of the dictionary does not match API key of the pair") + } + if missingSecret { + problems = append(problems, "API secret missing") + } + output.ErrPrintf(false, "There are malformed Global API key pair entries under context \"%s\". Issues: %s. Deleting the malformed entries.\n", c.Name, strings.Join(problems, ", ")) + } + if c.ActiveGlobalAPIKey != "" { + if _, ok := c.GlobalAPIKeys[c.ActiveGlobalAPIKey]; !ok { + output.ErrPrintf(false, "Active Global API key \"%s\" has no info stored for context \"%s\". Removing active setting.\n", c.ActiveGlobalAPIKey, c.Name) + c.ActiveGlobalAPIKey = "" + } + } +} + func (c *Context) Save() error { return c.Config.Save() } @@ -509,6 +559,101 @@ func printApiKeysDictErrorMessage(missingKey, mismatchKey, missingSecret bool, c output.ErrPrintf(false, "You can re-add the API key pair with `confluent api-key store --resource %s`\n", cluster.ID) } +// EncryptGlobalAPIKeys encrypts every stored Global API key secret in place. Idempotent: pairs that are +// already encrypted are skipped (matching APIKeyPair.EncryptSecret semantics). +func (c *Context) EncryptGlobalAPIKeys() error { + for _, pair := range c.GlobalAPIKeys { + if err := pair.EncryptSecret(); err != nil { + return err + } + } + return nil +} + +// DecryptGlobalAPIKeys decrypts every stored Global API key secret in place. +func (c *Context) DecryptGlobalAPIKeys() error { + for _, pair := range c.GlobalAPIKeys { + if err := pair.DecryptSecret(); err != nil { + return err + } + } + return nil +} + +func (c *Context) HasGlobalAPIKey(key string) bool { + if c == nil || c.GlobalAPIKeys == nil { + return false + } + _, ok := c.GlobalAPIKeys[key] + return ok +} + +// StoreGlobalAPIKey inserts a Global API key pair into the org-level keystore and encrypts it. +// Callers must persist the Context (Config.Save) after this returns. +func (c *Context) StoreGlobalAPIKey(pair *APIKeyPair) error { + if c.GlobalAPIKeys == nil { + c.GlobalAPIKeys = map[string]*APIKeyPair{} + } + c.GlobalAPIKeys[pair.Key] = pair + return c.EncryptGlobalAPIKeys() +} + +func (c *Context) DeleteGlobalAPIKey(key string) { + if c == nil || c.GlobalAPIKeys == nil { + return + } + delete(c.GlobalAPIKeys, key) + if c.ActiveGlobalAPIKey == key { + c.ActiveGlobalAPIKey = "" + } +} + +func (c *Context) GetActiveGlobalAPIKey() string { + if c == nil { + return "" + } + return c.ActiveGlobalAPIKey +} + +// SetActiveGlobalAPIKey selects the given key as the active Global key. Returns an error if the key +// is not stored locally. +func (c *Context) SetActiveGlobalAPIKey(key string) error { + if !c.HasGlobalAPIKey(key) { + return fmt.Errorf("Global API key %q is not stored in local CLI state", key) + } + c.ActiveGlobalAPIKey = key + return nil +} + +// GetActiveGlobalAPIKeyPair returns the active Global API key pair, or nil if none is set. +func (c *Context) GetActiveGlobalAPIKeyPair() *APIKeyPair { + if c == nil || c.ActiveGlobalAPIKey == "" { + return nil + } + return c.GlobalAPIKeys[c.ActiveGlobalAPIKey] +} + +// ResolveKafkaAPIKey returns the API key/secret to use against the given Kafka cluster: prefer the +// cluster-scoped active key, falling back to the active Global key. Returned secret is decrypted. +// Returns ("", "", nil) if neither is set. +func (c *Context) ResolveKafkaAPIKey(kcc *KafkaClusterConfig) (string, string, error) { + if kcc != nil && kcc.APIKey != "" { + if pair, ok := kcc.APIKeys[kcc.APIKey]; ok { + if err := pair.DecryptSecret(); err != nil { + return "", "", err + } + return pair.Key, pair.Secret, nil + } + } + if pair := c.GetActiveGlobalAPIKeyPair(); pair != nil { + if err := pair.DecryptSecret(); err != nil { + return "", "", err + } + return pair.Key, pair.Secret, nil + } + return "", "", nil +} + func (c *Context) ParseFlagsIntoContext(cmd *cobra.Command) error { if environment, _ := cmd.Flags().GetString("environment"); environment != "" { if c.GetCredentialType() == APIKey { diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index 4d6036c5c3..698f1c18fe 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -111,3 +111,94 @@ func getEnvAndClusterFlagContext() *Context { return ctx } + +func TestContext_GlobalAPIKeyStorage(t *testing.T) { + ctx := getBaseContext() + require.NotNil(t, ctx.GlobalAPIKeys, "validate() should initialize GlobalAPIKeys") + + pair := &APIKeyPair{Key: "GLOBAL-KEY-1", Secret: "plain-secret"} + require.NoError(t, ctx.StoreGlobalAPIKey(pair)) + + require.True(t, ctx.HasGlobalAPIKey("GLOBAL-KEY-1")) + require.False(t, ctx.HasGlobalAPIKey("missing")) + + // After Store, the secret is encrypted in place. Resolve must decrypt it. + stored := ctx.GlobalAPIKeys["GLOBAL-KEY-1"] + require.NotEqual(t, "plain-secret", stored.Secret, "secret should be encrypted at rest") + + require.NoError(t, ctx.SetActiveGlobalAPIKey("GLOBAL-KEY-1")) + require.Equal(t, "GLOBAL-KEY-1", ctx.GetActiveGlobalAPIKey()) + + // SetActiveGlobalAPIKey must reject keys that aren't stored. + require.Error(t, ctx.SetActiveGlobalAPIKey("unknown-key")) +} + +func TestContext_ResolveKafkaAPIKey_PrefersClusterScoped(t *testing.T) { + ctx := getBaseContext() + + // Pre-populate a cluster-scoped key on the active cluster. + kcc := ctx.KafkaClusterContext.GetActiveKafkaClusterConfig() + require.NotNil(t, kcc) + clusterKey := &APIKeyPair{Key: "CLUSTER-KEY", Secret: "cluster-secret"} + require.NoError(t, clusterKey.EncryptSecret()) + kcc.APIKeys = map[string]*APIKeyPair{"CLUSTER-KEY": clusterKey} + kcc.APIKey = "CLUSTER-KEY" + + // Also set up a global key + active marker. + require.NoError(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "GLOBAL-KEY", Secret: "global-secret"})) + require.NoError(t, ctx.SetActiveGlobalAPIKey("GLOBAL-KEY")) + + key, secret, err := ctx.ResolveKafkaAPIKey(kcc) + require.NoError(t, err) + require.Equal(t, "CLUSTER-KEY", key) + require.Equal(t, "cluster-secret", secret) +} + +func TestContext_ResolveKafkaAPIKey_FallsBackToGlobal(t *testing.T) { + ctx := getBaseContext() + + kcc := ctx.KafkaClusterContext.GetActiveKafkaClusterConfig() + require.NotNil(t, kcc) + kcc.APIKey = "" + kcc.APIKeys = map[string]*APIKeyPair{} + + require.NoError(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "GLOBAL-KEY", Secret: "global-secret"})) + require.NoError(t, ctx.SetActiveGlobalAPIKey("GLOBAL-KEY")) + + key, secret, err := ctx.ResolveKafkaAPIKey(kcc) + require.NoError(t, err) + require.Equal(t, "GLOBAL-KEY", key) + require.Equal(t, "global-secret", secret) +} + +func TestContext_ResolveKafkaAPIKey_NoCredentialsConfigured(t *testing.T) { + ctx := getBaseContext() + kcc := ctx.KafkaClusterContext.GetActiveKafkaClusterConfig() + require.NotNil(t, kcc) + kcc.APIKey = "" + kcc.APIKeys = map[string]*APIKeyPair{} + + key, secret, err := ctx.ResolveKafkaAPIKey(kcc) + require.NoError(t, err) + require.Empty(t, key) + require.Empty(t, secret) +} + +func TestContext_DeleteGlobalAPIKey_ClearsActive(t *testing.T) { + ctx := getBaseContext() + require.NoError(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "GLOBAL-KEY", Secret: "global-secret"})) + require.NoError(t, ctx.SetActiveGlobalAPIKey("GLOBAL-KEY")) + + ctx.DeleteGlobalAPIKey("GLOBAL-KEY") + + require.False(t, ctx.HasGlobalAPIKey("GLOBAL-KEY")) + require.Empty(t, ctx.GetActiveGlobalAPIKey(), "deleting active key should clear ActiveGlobalAPIKey") +} + +func TestContext_ValidateGlobalAPIKeys_RemovesOrphanedActive(t *testing.T) { + ctx := getBaseContext() + // Active key references a pair that doesn't exist in the map. + ctx.ActiveGlobalAPIKey = "ghost-key" + ctx.validateGlobalAPIKeys() + require.Empty(t, ctx.ActiveGlobalAPIKey, "validate should clear active key when not present in map") +} diff --git a/pkg/keystore/keystore.go b/pkg/keystore/keystore.go index ce17ad478a..fa534089dd 100644 --- a/pkg/keystore/keystore.go +++ b/pkg/keystore/keystore.go @@ -36,6 +36,22 @@ func (c *ConfigKeyStore) StoreAPIKey(client *ccloudv2.Client, key *config.APIKey } func (c *ConfigKeyStore) DeleteAPIKey(key string) error { - c.Config.Context().KafkaClusterContext.DeleteApiKey(key) + ctx := c.Config.Context() + ctx.KafkaClusterContext.DeleteApiKey(key) + ctx.DeleteGlobalAPIKey(key) + return c.Config.Save() +} + +// HasGlobalAPIKey reports whether a Global API key with the given id is stored locally. +func (c *ConfigKeyStore) HasGlobalAPIKey(key string) bool { + return c.Config.Context().HasGlobalAPIKey(key) +} + +// StoreGlobalAPIKey persists a Global API key pair on the active context. The secret is encrypted +// at rest, matching the behavior of StoreAPIKey for cluster-scoped keys. +func (c *ConfigKeyStore) StoreGlobalAPIKey(pair *config.APIKeyPair) error { + if err := c.Config.Context().StoreGlobalAPIKey(pair); err != nil { + return err + } return c.Config.Save() } From 02f439664d7c10974aa07628224dcd90c3043138 Mon Sep 17 00:00:00 2001 From: Taohao Wang Date: Mon, 1 Jun 2026 16:06:46 -0400 Subject: [PATCH 2/5] [IDENTITY-7450] Address review: validate Global key pair, error on broken cluster key, add tests. --- pkg/config/context.go | 21 +++++--- pkg/config/context_test.go | 29 +++++++++++ test/api_key_test.go | 50 +++++++++++++++++++ test/fixtures/output/api-key/30.golden | 1 + test/fixtures/output/api-key/33.golden | 1 + test/fixtures/output/api-key/41.golden | 1 + test/fixtures/output/api-key/43.golden | 1 + test/fixtures/output/api-key/56.golden | 1 + test/fixtures/output/api-key/6.golden | 1 + test/fixtures/output/api-key/7.golden | 9 ++++ test/fixtures/output/api-key/8.golden | 7 +++ .../api-key/describe-autocomplete.golden | 1 + .../output/api-key/global/store-force.golden | 1 + .../global/store-not-global-error.golden | 4 ++ .../global/store-override-error.golden | 4 ++ .../store-resource-mismatch-error.golden | 4 ++ .../output/api-key/global/store.golden | 1 + .../fixtures/output/api-key/global/use.golden | 1 + test/test-server/utils.go | 12 +++++ 19 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/output/api-key/global/store-force.golden create mode 100644 test/fixtures/output/api-key/global/store-not-global-error.golden create mode 100644 test/fixtures/output/api-key/global/store-override-error.golden create mode 100644 test/fixtures/output/api-key/global/store-resource-mismatch-error.golden create mode 100644 test/fixtures/output/api-key/global/store.golden create mode 100644 test/fixtures/output/api-key/global/use.golden diff --git a/pkg/config/context.go b/pkg/config/context.go index fcc9f26629..631f90f54a 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -591,6 +591,9 @@ func (c *Context) HasGlobalAPIKey(key string) bool { // StoreGlobalAPIKey inserts a Global API key pair into the org-level keystore and encrypts it. // Callers must persist the Context (Config.Save) after this returns. func (c *Context) StoreGlobalAPIKey(pair *APIKeyPair) error { + if pair == nil || pair.Key == "" || pair.Secret == "" { + return fmt.Errorf("Global API key pair must include both key and secret") + } if c.GlobalAPIKeys == nil { c.GlobalAPIKeys = map[string]*APIKeyPair{} } @@ -634,16 +637,20 @@ func (c *Context) GetActiveGlobalAPIKeyPair() *APIKeyPair { } // ResolveKafkaAPIKey returns the API key/secret to use against the given Kafka cluster: prefer the -// cluster-scoped active key, falling back to the active Global key. Returned secret is decrypted. -// Returns ("", "", nil) if neither is set. +// cluster-scoped active key, falling back to the active Global key only when no cluster-scoped key is +// set. Returned secret is decrypted. Returns ("", "", nil) if neither is set, and an error if the +// cluster's active key is set but its secret is missing locally (a broken cluster-scoped config that +// must not be silently masked by the Global fallback). func (c *Context) ResolveKafkaAPIKey(kcc *KafkaClusterConfig) (string, string, error) { if kcc != nil && kcc.APIKey != "" { - if pair, ok := kcc.APIKeys[kcc.APIKey]; ok { - if err := pair.DecryptSecret(); err != nil { - return "", "", err - } - return pair.Key, pair.Secret, nil + pair, ok := kcc.APIKeys[kcc.APIKey] + if !ok { + return "", "", fmt.Errorf("current Kafka API key %q is set for cluster %q but no secret is stored locally", kcc.APIKey, kcc.ID) } + if err := pair.DecryptSecret(); err != nil { + return "", "", err + } + return pair.Key, pair.Secret, nil } if pair := c.GetActiveGlobalAPIKeyPair(); pair != nil { if err := pair.DecryptSecret(); err != nil { diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index 698f1c18fe..62ea13553c 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -202,3 +202,32 @@ func TestContext_ValidateGlobalAPIKeys_RemovesOrphanedActive(t *testing.T) { ctx.validateGlobalAPIKeys() require.Empty(t, ctx.ActiveGlobalAPIKey, "validate should clear active key when not present in map") } + +func TestContext_StoreGlobalAPIKey_RejectsIncompletePair(t *testing.T) { + ctx := getBaseContext() + + require.Error(t, ctx.StoreGlobalAPIKey(nil), "nil pair should be rejected") + require.Error(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Secret: "secret-only"}), "missing key should be rejected") + require.Error(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "key-only"}), "missing secret should be rejected") + + // A malformed pair must not be persisted. + require.False(t, ctx.HasGlobalAPIKey("key-only")) + require.Empty(t, ctx.GlobalAPIKeys) +} + +func TestContext_ResolveKafkaAPIKey_ErrorsWhenClusterSecretMissing(t *testing.T) { + ctx := getBaseContext() + + // Cluster has an active key set, but its secret is not stored locally. + kcc := ctx.KafkaClusterContext.GetActiveKafkaClusterConfig() + require.NotNil(t, kcc) + kcc.APIKey = "CLUSTER-KEY" + kcc.APIKeys = map[string]*APIKeyPair{} + + // A usable Global key exists, but it must NOT be silently used to mask the broken cluster config. + require.NoError(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "GLOBAL-KEY", Secret: "global-secret"})) + require.NoError(t, ctx.SetActiveGlobalAPIKey("GLOBAL-KEY")) + + _, _, err := ctx.ResolveKafkaAPIKey(kcc) + require.Error(t, err, "should surface the broken cluster-scoped config rather than fall back to Global") +} diff --git a/test/api_key_test.go b/test/api_key_test.go index f7cc92d0cb..5b316b545c 100644 --- a/test/api_key_test.go +++ b/test/api_key_test.go @@ -156,6 +156,56 @@ func (s *CLITestSuite) TestApiKey() { } } +func (s *CLITestSuite) TestApiKeyGlobal() { + tests := []CLITest{ + // store a Global API key: auto-detected from the server-side resource Kind (no --resource needed) + {args: "api-key store UIGLOBALKEY100 UIGLOBALSECRET100", login: "cloud", fixture: "api-key/global/store.golden"}, + + // storing again without --force is refused + {args: "api-key store UIGLOBALKEY100 NEWSECRET", fixture: "api-key/global/store-override-error.golden", exitCode: 1}, + + // --force overwrites the stored secret + { + args: "api-key store UIGLOBALKEY100 NEWSECRET --force", fixture: "api-key/global/store-force.golden", + wantFunc: func(t *testing.T) { + cfg := config.New() + require.NoError(t, cfg.Load()) + ctx := cfg.Context() + require.NotNil(t, ctx) + require.NoError(t, ctx.DecryptGlobalAPIKeys()) + pair := ctx.GlobalAPIKeys["UIGLOBALKEY100"] + require.NotNil(t, pair) + require.Equal(t, "NEWSECRET", pair.Secret) + }, + }, + + // use the stored Global key as the active Global key + { + args: "api-key use UIGLOBALKEY100", fixture: "api-key/global/use.golden", + wantFunc: func(t *testing.T) { + cfg := config.New() + require.NoError(t, cfg.Load()) + ctx := cfg.Context() + require.NotNil(t, ctx) + require.Equal(t, "UIGLOBALKEY100", ctx.GetActiveGlobalAPIKey()) + }, + }, + + // guard: --resource global on a non-Global (cluster) key is rejected + {args: "api-key store UIAPIKEY100 UIAPISECRET100 --resource global", fixture: "api-key/global/store-not-global-error.golden", exitCode: 1}, + + // guard: a cluster --resource on a Global key is rejected + {args: "api-key store UIGLOBALKEY100 NEWSECRET --resource lkc-cool1", fixture: "api-key/global/store-resource-mismatch-error.golden", exitCode: 1}, + } + + resetConfiguration(s.T(), false) + + for _, test := range tests { + test.workflow = true + s.runIntegrationTest(test) + } +} + func (s *CLITestSuite) TestApiKeyCreate() { tests := []CLITest{ {args: "api-key create --resource flink --cloud aws --region us-east-1", fixture: "api-key/create-flink.golden"}, diff --git a/test/fixtures/output/api-key/30.golden b/test/fixtures/output/api-key/30.golden index 8d665b30e5..b2ac7c9682 100644 --- a/test/fixtures/output/api-key/30.golden +++ b/test/fixtures/output/api-key/30.golden @@ -18,3 +18,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/33.golden b/test/fixtures/output/api-key/33.golden index b64110ed09..8880b6563e 100644 --- a/test/fixtures/output/api-key/33.golden +++ b/test/fixtures/output/api-key/33.golden @@ -19,3 +19,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/41.golden b/test/fixtures/output/api-key/41.golden index a517b85e5a..d5fd8a2699 100644 --- a/test/fixtures/output/api-key/41.golden +++ b/test/fixtures/output/api-key/41.golden @@ -21,3 +21,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/43.golden b/test/fixtures/output/api-key/43.golden index 6e65d99585..425b307915 100644 --- a/test/fixtures/output/api-key/43.golden +++ b/test/fixtures/output/api-key/43.golden @@ -22,3 +22,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/56.golden b/test/fixtures/output/api-key/56.golden index 4e2b90a544..b25ddeddaf 100644 --- a/test/fixtures/output/api-key/56.golden +++ b/test/fixtures/output/api-key/56.golden @@ -32,3 +32,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/6.golden b/test/fixtures/output/api-key/6.golden index 74ddca6731..841f461d48 100644 --- a/test/fixtures/output/api-key/6.golden +++ b/test/fixtures/output/api-key/6.golden @@ -14,3 +14,4 @@ | UIAPIKEY101 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-other1 | 1999-02-24T00:00:00Z | UIAPIKEY102 | | u-22bbb | u-22bbb@confluent.io | ksql | lksqlc-ksql1 | 1999-02-24T00:00:00Z | UIAPIKEY103 | | u-22bbb | u-22bbb@confluent.io | kafka | lkc-cool1 | 1999-02-24T00:00:00Z + | UIGLOBALKEY100 | | u-22bbb | u-22bbb@confluent.io | global | | 1999-02-24T00:00:00Z diff --git a/test/fixtures/output/api-key/7.golden b/test/fixtures/output/api-key/7.golden index c580b2c69b..e079df5e44 100644 --- a/test/fixtures/output/api-key/7.golden +++ b/test/fixtures/output/api-key/7.golden @@ -106,5 +106,14 @@ "resource_type": "kafka", "resource": "lkc-cool1", "created": "1999-02-24T00:00:00Z" + }, + { + "key": "UIGLOBALKEY100", + "description": "", + "owner": "u-22bbb", + "owner_email": "u-22bbb@confluent.io", + "resource_type": "global", + "resource": "", + "created": "1999-02-24T00:00:00Z" } ] diff --git a/test/fixtures/output/api-key/8.golden b/test/fixtures/output/api-key/8.golden index 81d478c442..3ee70bd50f 100644 --- a/test/fixtures/output/api-key/8.golden +++ b/test/fixtures/output/api-key/8.golden @@ -82,3 +82,10 @@ resource_type: kafka resource: lkc-cool1 created: "1999-02-24T00:00:00Z" +- key: UIGLOBALKEY100 + description: "" + owner: u-22bbb + owner_email: u-22bbb@confluent.io + resource_type: global + resource: "" + created: "1999-02-24T00:00:00Z" diff --git a/test/fixtures/output/api-key/describe-autocomplete.golden b/test/fixtures/output/api-key/describe-autocomplete.golden index 56b23624d7..f978d3192e 100644 --- a/test/fixtures/output/api-key/describe-autocomplete.golden +++ b/test/fixtures/output/api-key/describe-autocomplete.golden @@ -23,5 +23,6 @@ UIAPIKEY100 UIAPIKEY101 UIAPIKEY102 UIAPIKEY103 +UIGLOBALKEY100 :4 Completion ended with directive: ShellCompDirectiveNoFileComp diff --git a/test/fixtures/output/api-key/global/store-force.golden b/test/fixtures/output/api-key/global/store-force.golden new file mode 100644 index 0000000000..4724698ce2 --- /dev/null +++ b/test/fixtures/output/api-key/global/store-force.golden @@ -0,0 +1 @@ +Stored secret for Global API key "UIGLOBALKEY100". diff --git a/test/fixtures/output/api-key/global/store-not-global-error.golden b/test/fixtures/output/api-key/global/store-not-global-error.golden new file mode 100644 index 0000000000..bcdc7a87a8 --- /dev/null +++ b/test/fixtures/output/api-key/global/store-not-global-error.golden @@ -0,0 +1,4 @@ +Error: API key "UIAPIKEY100" is not a Global API key + +Suggestions: + Omit `--resource global`, or pass the correct resource ID. diff --git a/test/fixtures/output/api-key/global/store-override-error.golden b/test/fixtures/output/api-key/global/store-override-error.golden new file mode 100644 index 0000000000..0060fc8bee --- /dev/null +++ b/test/fixtures/output/api-key/global/store-override-error.golden @@ -0,0 +1,4 @@ +Error: refusing to overwrite existing secret for API Key "UIGLOBALKEY100" + +Suggestions: + If you would like to override the existing secret stored for API key "UIGLOBALKEY100", use the `--force` flag. diff --git a/test/fixtures/output/api-key/global/store-resource-mismatch-error.golden b/test/fixtures/output/api-key/global/store-resource-mismatch-error.golden new file mode 100644 index 0000000000..3383ba7d0c --- /dev/null +++ b/test/fixtures/output/api-key/global/store-resource-mismatch-error.golden @@ -0,0 +1,4 @@ +Error: API key "UIGLOBALKEY100" is a Global API key, but --resource was set to "Kafka cluster" + +Suggestions: + Re-run with `--resource global`, or omit `--resource` to auto-detect. diff --git a/test/fixtures/output/api-key/global/store.golden b/test/fixtures/output/api-key/global/store.golden new file mode 100644 index 0000000000..4724698ce2 --- /dev/null +++ b/test/fixtures/output/api-key/global/store.golden @@ -0,0 +1 @@ +Stored secret for Global API key "UIGLOBALKEY100". diff --git a/test/fixtures/output/api-key/global/use.golden b/test/fixtures/output/api-key/global/use.golden new file mode 100644 index 0000000000..b9764fcdf4 --- /dev/null +++ b/test/fixtures/output/api-key/global/use.golden @@ -0,0 +1 @@ +Using Global API Key "UIGLOBALKEY100". diff --git a/test/test-server/utils.go b/test/test-server/utils.go index 4077a78dd9..83c53103e5 100644 --- a/test/test-server/utils.go +++ b/test/test-server/utils.go @@ -189,6 +189,18 @@ func fillKeyStoreV2() { Description: apikeysv2.PtrString(""), }, } + keyStoreV2["UIGLOBALKEY100"] = &apikeysv2.IamV2ApiKey{ + Id: apikeysv2.PtrString("UIGLOBALKEY100"), + Spec: &apikeysv2.IamV2ApiKeySpec{ + Resource: &apikeysv2.ObjectReference{ + Id: "global", + ApiVersion: apikeysv2.PtrString("iam/v2"), + Kind: apikeysv2.PtrString("Global"), + }, + Owner: &apikeysv2.ObjectReference{Id: "u-22bbb"}, + Description: apikeysv2.PtrString(""), + }, + } keyStoreV2["SERVICEACCOUNTKEY1"] = &apikeysv2.IamV2ApiKey{ Id: apikeysv2.PtrString("SERVICEACCOUNTKEY1"), Spec: &apikeysv2.IamV2ApiKeySpec{ From f4d64006c4011ee0fa4e9f5d5ba2ee3db6218fde Mon Sep 17 00:00:00 2001 From: Taohao Wang Date: Mon, 1 Jun 2026 16:43:24 -0400 Subject: [PATCH 3/5] Update tests --- pkg/config/context_test.go | 52 +++++++++++++++++++ test/api_key_test.go | 12 +++++ .../api-key/describe-autocomplete.golden | 1 + .../output/api-key/global/create-use.golden | 7 +++ 4 files changed, 72 insertions(+) create mode 100644 test/fixtures/output/api-key/global/create-use.golden diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index 62ea13553c..438bac2ebd 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -215,6 +215,58 @@ func TestContext_StoreGlobalAPIKey_RejectsIncompletePair(t *testing.T) { require.Empty(t, ctx.GlobalAPIKeys) } +func TestContext_ValidateGlobalAPIKeys_DeletesMalformedEntries(t *testing.T) { + ctx := getBaseContext() + ctx.GlobalAPIKeys = map[string]*APIKeyPair{ + "nil-pair": nil, // missing key (nil pair) + "empty-key": {Key: "", Secret: "s"}, // missing key (empty Key) + "MISMATCH": {Key: "OTHER", Secret: "s"}, // key/pair mismatch + "NO-SECRET": {Key: "NO-SECRET", Secret: ""}, // missing secret + "GOOD": {Key: "GOOD", Secret: "s"}, // valid, must survive + } + + ctx.validateGlobalAPIKeys() + + require.Len(t, ctx.GlobalAPIKeys, 1) + require.True(t, ctx.HasGlobalAPIKey("GOOD")) + require.False(t, ctx.HasGlobalAPIKey("nil-pair")) + require.False(t, ctx.HasGlobalAPIKey("empty-key")) + require.False(t, ctx.HasGlobalAPIKey("MISMATCH")) + require.False(t, ctx.HasGlobalAPIKey("NO-SECRET")) +} + +func TestContext_EncryptDecryptGlobalAPIKeys(t *testing.T) { + ctx := getBaseContext() + ctx.GlobalAPIKeys = map[string]*APIKeyPair{"K": {Key: "K", Secret: "plain-secret"}} + + require.NoError(t, ctx.EncryptGlobalAPIKeys()) + require.NotEqual(t, "plain-secret", ctx.GlobalAPIKeys["K"].Secret, "secret should be encrypted in place") + + require.NoError(t, ctx.DecryptGlobalAPIKeys()) + require.Equal(t, "plain-secret", ctx.GlobalAPIKeys["K"].Secret, "secret should round-trip back to plaintext") +} + +func TestContext_GlobalAPIKey_NilAndEmptyReceiverSafety(t *testing.T) { + var nilCtx *Context + require.False(t, nilCtx.HasGlobalAPIKey("x")) + require.Empty(t, nilCtx.GetActiveGlobalAPIKey()) + require.Nil(t, nilCtx.GetActiveGlobalAPIKeyPair()) + require.NotPanics(t, func() { nilCtx.DeleteGlobalAPIKey("x") }) + + // Non-nil context with no active key set: GetActiveGlobalAPIKeyPair returns nil. + ctx := getBaseContext() + require.Empty(t, ctx.GetActiveGlobalAPIKey()) + require.Nil(t, ctx.GetActiveGlobalAPIKeyPair()) + + // Deleting a key that isn't present is a no-op and does not panic. + require.NotPanics(t, func() { ctx.DeleteGlobalAPIKey("not-stored") }) + + // StoreGlobalAPIKey lazily initializes a nil map. + ctx.GlobalAPIKeys = nil + require.NoError(t, ctx.StoreGlobalAPIKey(&APIKeyPair{Key: "K", Secret: "s"})) + require.True(t, ctx.HasGlobalAPIKey("K")) +} + func TestContext_ResolveKafkaAPIKey_ErrorsWhenClusterSecretMissing(t *testing.T) { ctx := getBaseContext() diff --git a/test/api_key_test.go b/test/api_key_test.go index 5b316b545c..9c1fa245d2 100644 --- a/test/api_key_test.go +++ b/test/api_key_test.go @@ -196,6 +196,18 @@ func (s *CLITestSuite) TestApiKeyGlobal() { // guard: a cluster --resource on a Global key is rejected {args: "api-key store UIGLOBALKEY100 NEWSECRET --resource lkc-cool1", fixture: "api-key/global/store-resource-mismatch-error.golden", exitCode: 1}, + + // create a Global key with --use: stores it locally and sets it as the active Global key + { + args: "api-key create --description created-and-used --resource global --use", fixture: "api-key/global/create-use.golden", + wantFunc: func(t *testing.T) { + cfg := config.New() + require.NoError(t, cfg.Load()) + ctx := cfg.Context() + require.NotNil(t, ctx) + require.NotEmpty(t, ctx.GetActiveGlobalAPIKey(), "create --use should set an active Global key") + }, + }, } resetConfiguration(s.T(), false) diff --git a/test/fixtures/output/api-key/describe-autocomplete.golden b/test/fixtures/output/api-key/describe-autocomplete.golden index f978d3192e..11d7dd9a16 100644 --- a/test/fixtures/output/api-key/describe-autocomplete.golden +++ b/test/fixtures/output/api-key/describe-autocomplete.golden @@ -14,6 +14,7 @@ MYKEY17 MYKEY18 MYKEY19 human-output MYKEY2 +MYKEY20 created-and-used MYKEY3 MYKEY4 my-cool-app MYKEY6 my-ksql-app diff --git a/test/fixtures/output/api-key/global/create-use.golden b/test/fixtures/output/api-key/global/create-use.golden new file mode 100644 index 0000000000..def733355d --- /dev/null +++ b/test/fixtures/output/api-key/global/create-use.golden @@ -0,0 +1,7 @@ +It may take a couple of minutes for the API key to be ready. +Save the API key and secret. The secret is not retrievable later. ++------------+------------+ +| API Key | MYKEY20 | +| API Secret | MYSECRET20 | ++------------+------------+ +Using Global API Key "MYKEY20". From 243cfe413632f245a73590dc1d97df5f938247c6 Mon Sep 17 00:00:00 2001 From: Taohao Wang Date: Thu, 4 Jun 2026 16:16:46 -0400 Subject: [PATCH 4/5] Add support for command async apis --- internal/asyncapi/command_export.go | 12 ++++++++++-- test/asyncapi_test.go | 4 ++++ .../client-config/csharp-global-key.golden | 10 ++++++++++ test/kafka_test.go | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/output/kafka/client-config/csharp-global-key.golden diff --git a/internal/asyncapi/command_export.go b/internal/asyncapi/command_export.go index 1b31206afe..cfc8f29d88 100644 --- a/internal/asyncapi/command_export.go +++ b/internal/asyncapi/command_export.go @@ -491,12 +491,20 @@ func (c *command) getClusterDetails(details *accountDetails, flags *flags, cmd * } clusterCreds = cluster.APIKeys[flags.kafkaApiKey] } else { - clusterCreds = cluster.APIKeys[cluster.APIKey] + // No explicit --kafka-api-key: resolve the cluster-scoped active key, falling back to the + // active Global API key (see Context.ResolveKafkaAPIKey). + apiKey, apiSecret, err := c.Context.ResolveKafkaAPIKey(cluster) + if err != nil { + return err + } + if apiKey != "" { + clusterCreds = &config.APIKeyPair{Key: apiKey, Secret: apiSecret} + } } if clusterCreds == nil { return errors.NewErrorWithSuggestions( "API key not set for the Kafka cluster", - "Set an API key pair for the Kafka cluster using `confluent api-key create --resource ` and then use it with `--kafka-api-key`.", + "Set an API key pair for the Kafka cluster using `confluent api-key create --resource ` and then use it with `--kafka-api-key`, or set an active Global API key with `confluent api-key use`.", ) } diff --git a/test/asyncapi_test.go b/test/asyncapi_test.go index 1a72aee22f..c6b1cc22d5 100644 --- a/test/asyncapi_test.go +++ b/test/asyncapi_test.go @@ -13,6 +13,10 @@ func (s *CLITestSuite) TestAsyncapiExport() { tests := []CLITest{ {args: "asyncapi export", exitCode: 1, fixture: "asyncapi/no-kafka.golden"}, {args: "environment use " + testserver.SRApiEnvId}, + // Global API key fallback: with only an active Global key and no cluster-scoped key, export succeeds. + {args: "api-key store UIGLOBALKEY100 UIGLOBALSECRET100"}, + {args: "api-key use UIGLOBALKEY100"}, + {args: "asyncapi export", fixture: "asyncapi/export-success.golden", useKafka: "lkc-asyncapi"}, // Spec Generated {args: "asyncapi export", fixture: "asyncapi/export-success.golden", useKafka: "lkc-asyncapi", authKafka: true}, {args: "asyncapi export --schema-context dev --file asyncapi-with-context.yaml", useKafka: "lkc-asyncapi", authKafka: true}, diff --git a/test/fixtures/output/kafka/client-config/csharp-global-key.golden b/test/fixtures/output/kafka/client-config/csharp-global-key.golden new file mode 100644 index 0000000000..eb7441d218 --- /dev/null +++ b/test/fixtures/output/kafka/client-config/csharp-global-key.golden @@ -0,0 +1,10 @@ +# Required connection configs for Kafka producer, consumer, and admin +bootstrap.servers=kafka-endpoint +security.protocol=SASL_SSL +sasl.mechanisms=PLAIN +sasl.username=UIGLOBALKEY100 +sasl.password=UIGLOBALSECRET100 + +# Best practice for higher availability in librdkafka clients prior to 1.7 +session.timeout.ms=45000 + diff --git a/test/kafka_test.go b/test/kafka_test.go index c80b845bdc..44ecc0280d 100644 --- a/test/kafka_test.go +++ b/test/kafka_test.go @@ -313,6 +313,25 @@ func (s *CLITestSuite) TestKafkaClientConfig() { } } +func (s *CLITestSuite) TestKafkaClientConfigGlobalKey() { + tests := []CLITest{ + // Store and activate a Global key, with no cluster-scoped key set. + {args: "api-key store UIGLOBALKEY100 UIGLOBALSECRET100"}, + {args: "api-key use UIGLOBALKEY100"}, + // client-config create falls back to the active Global key: the rendered config embeds it as + // sasl.username/sasl.password (csharp template needs no Schema Registry key-secret pair). + {args: "kafka client-config create csharp", useKafka: "lkc-cool1", fixture: "kafka/client-config/csharp-global-key.golden"}, + } + + resetConfiguration(s.T(), false) + + for _, test := range tests { + test.login = "cloud" + test.workflow = true + s.runIntegrationTest(test) + } +} + func getCreateLinkConfigFile() string { file, _ := os.CreateTemp(os.TempDir(), "test") _, _ = file.Write([]byte("key=val\n key2=val2 \n key3=val password=pass")) From f0966f57a0183bebfc60bd1f1ad8ba10d9848bdb Mon Sep 17 00:00:00 2001 From: Taohao Wang Date: Thu, 11 Jun 2026 15:35:09 -0400 Subject: [PATCH 5/5] Update nonKafkaNotImplementedErrorMsg to include global key support --- internal/api-key/command.go | 2 +- test/fixtures/output/api-key/61.golden | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api-key/command.go b/internal/api-key/command.go index d5fb33cdf6..f0fe6a7b20 100644 --- a/internal/api-key/command.go +++ b/internal/api-key/command.go @@ -32,7 +32,7 @@ const ( apiKeyNotValidForClusterSuggestions = "Specify the cluster this API key belongs to using the `--resource` flag. Alternatively, first execute the `confluent kafka cluster use` command to set the context to the proper cluster for this key and retry the `confluent api-key store` command." apiKeyUseFailedErrorMsg = "unable to set active API key" apiKeyUseFailedSuggestions = "If you did not create this API key with the CLI or created it on another computer, you must first store the API key and secret locally with `confluent api-key store %s `." - nonKafkaNotImplementedErrorMsg = "functionality not yet available for non-Kafka cluster resources" + nonKafkaNotImplementedErrorMsg = "functionality not yet available for resources other than Kafka clusters and Global API keys" refuseToOverrideSecretSuggestions = "If you would like to override the existing secret stored for API key \"%s\", use the `--force` flag." unableToStoreApiKeyErrorMsg = "unable to store API key locally: %w" ) diff --git a/test/fixtures/output/api-key/61.golden b/test/fixtures/output/api-key/61.golden index d7d4484a73..1fe3939e44 100644 --- a/test/fixtures/output/api-key/61.golden +++ b/test/fixtures/output/api-key/61.golden @@ -1 +1 @@ -Error: functionality not yet available for non-Kafka cluster resources +Error: functionality not yet available for resources other than Kafka clusters and Global API keys