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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions go.sum

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions internal/organization/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func New(prerunner pcmd.PreRunner) *cobra.Command {
cmd.AddCommand(c.newDescribeCommand())
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(c.newUpdateCommand())
cmd.AddCommand(c.newUseCommand())

return cmd
}
88 changes: 88 additions & 0 deletions internal/organization/command_use.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package organization

import (
"fmt"

"github.com/spf13/cobra"

pcmd "github.com/confluentinc/cli/v4/pkg/cmd"
"github.com/confluentinc/cli/v4/pkg/errors"
"github.com/confluentinc/cli/v4/pkg/output"
"github.com/confluentinc/cli/v4/pkg/resource"
)

func (c *command) newUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use <id>",
Short: "Use a Confluent Cloud organization in subsequent commands.",
Long: "Choose a Confluent Cloud organization to be used in subsequent commands. Switching organizations clears your active environment and Kafka cluster selections.",
Args: cobra.ExactArgs(1),
ValidArgsFunction: pcmd.NewValidArgsFunction(c.validArgs),
RunE: c.use,
}

return cmd
}

func (c *command) validArgs(cmd *cobra.Command, args []string) []string {
if len(args) > 0 {
return nil
}

if err := c.PersistentPreRunE(cmd, args); err != nil {

Check warning on line 32 in internal/organization/command_use.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Remove this unnecessary variable declaration and use the expression directly in the condition.

[S8193] Variables in if short statements should be used beyond just the condition See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=cddbc1fb-3db8-4f9c-a266-1c513744e030&open=cddbc1fb-3db8-4f9c-a266-1c513744e030
return nil
}

organizations, err := c.V2Client.ListOrgOrganizations()
if err != nil {
return nil
}

suggestions := make([]string, len(organizations))
for i, org := range organizations {
suggestions[i] = fmt.Sprintf("%s\t%s", org.GetId(), org.GetDisplayName())
}
return suggestions
}

func (c *command) use(_ *cobra.Command, args []string) error {
id := args[0]

// Use the list endpoint instead of get-by-ID because the org-scoped JWT
// only authorizes reading the *current* organization. The list endpoint
// returns every organization the user belongs to regardless of JWT scope.
organizations, err := c.V2Client.ListOrgOrganizations()
if err != nil {
return fmt.Errorf("failed to list organizations: %w", err)
}

found := false
for _, org := range organizations {
if org.GetId() == id {
found = true
break
}
}
if !found {
return errors.NewErrorWithSuggestions(
fmt.Sprintf(`organization "%s" not found or access forbidden`, id),
"List available organizations with `organization list`.",
)
}

if id == c.Context.GetCurrentOrganization() {
output.Printf(c.Config.EnableColor, errors.UsingResourceMsg, resource.Organization, id)
return nil
}

if err := c.Context.SwitchOrganization(c.Client, id); err != nil {
return fmt.Errorf("failed to switch to organization %q: %w", id, err)
}

if err := c.Config.Save(); err != nil {
return err
}

output.Printf(c.Config.EnableColor, errors.UsingResourceMsg, resource.Organization, id)
return nil
}
36 changes: 36 additions & 0 deletions pkg/config/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,42 @@ func (c *Context) UpdateAuthTokens(token, refreshToken string) error {
return c.Save()
}

// SwitchOrganization switches the context to a different Confluent Cloud
// organization by minting a new org-scoped JWT using the existing user-scoped
// refresh token. It clears all environment-scoped state (environments, Kafka
// clusters) since those are org-scoped. The caller must call Config.Save()
// after this method returns successfully.
func (c *Context) SwitchOrganization(client *ccloudv1.Client, orgId string) error {
previousOrgId := c.LastOrgId
previousAuthToken := c.State.AuthToken
previousRefreshToken := c.State.AuthRefreshToken

// Set the target org before refreshing so RefreshSession picks it up
// via GetCurrentOrganization().
c.LastOrgId = orgId

if err := c.RefreshSession(client); err != nil {
c.LastOrgId = previousOrgId
c.State.AuthToken = previousAuthToken

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from Steven Gagniere (@sgagniere) :

appears to use a refresh token from a login to one organization to get a token for a different organization.
Is that how the refresh tokens are intended to work on the backend?

c.State.AuthRefreshToken = previousRefreshToken
return err
}

// Clear all environment-scoped state: environments and Kafka clusters
// belong to a specific org, so stale IDs from the previous org must not
// leak into the new context.
c.CurrentEnvironment = ""
c.Environments = map[string]*EnvironmentContext{}
if c.KafkaClusterContext != nil {
c.KafkaClusterContext.ActiveKafkaCluster = ""
c.KafkaClusterContext.ActiveKafkaClusterEndpoint = ""
c.KafkaClusterContext.KafkaClusterConfigs = map[string]*KafkaClusterConfig{}
c.KafkaClusterContext.KafkaEnvContexts = map[string]*KafkaEnvContext{}
}
Comment on lines +121 to +146
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwitchOrganization updates Context.LastOrgId but does not update/clear c.State.Auth.Organization (or related org-scoped auth metadata). After a successful switch, GetCurrentOrganization() and GetOrganization() can diverge, and org suspension checks (Config.isOrgSuspended / isLoginBlockedByOrgSuspension) may still evaluate using the previous org’s suspension status. Consider updating the in-memory AuthConfig to reflect the new org (or clearing it and forcing a refresh) and include it in the rollback path if RefreshSession fails.

Copilot uses AI. Check for mistakes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwitchOrganization intentionally does NOT update State.Auth.Organization. It only:

  1. Sets LastOrgId (which is what GetCurrentOrganization() returns)
  2. Calls RefreshSession to mint a new org-scoped JWT
  3. Clears environment/Kafka state

The State.Auth.Organization field is populated during the login flow (via the /api/me endpoint), not during org switching. The functional org identity used for all API calls comes
from the JWT and LastOrgId, not from Auth.Organization.

Adding the assertion Copilot suggests would actually fail — because SwitchOrganization doesn't update that field, and that's by design. Updating Auth.Organization would require an
additional /api/me API call, which is unnecessary since the new JWT is already org-scoped.


return nil
}

func (c *Context) IsCloud(isTest bool) bool {
if isTest && c.PlatformName == testserver.TestCloudUrl.String() {
return true
Expand Down
121 changes: 121 additions & 0 deletions pkg/config/context_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package config

import (
"fmt"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

ccloudv1 "github.com/confluentinc/ccloud-sdk-go-v1-public"
ccloudv1mock "github.com/confluentinc/ccloud-sdk-go-v1-public/mock"
)

var (
Expand Down Expand Up @@ -77,6 +81,123 @@
}
}

func TestSwitchOrganization(t *testing.T) {
cfg := AuthenticatedCloudConfigMock()
ctx := cfg.Context()

oldRefreshToken := "old-refresh-token"
ctx.State.AuthRefreshToken = oldRefreshToken

newOrgId := "org-new-id"
newToken := "new-auth-token"
newRefreshToken := "new-refresh-token"

auth := &ccloudv1mock.Auth{
LoginFunc: func(req *ccloudv1.AuthenticateRequest) (*ccloudv1.AuthenticateReply, error) {
require.Equal(t, newOrgId, req.OrgResourceId)
require.Equal(t, oldRefreshToken, req.RefreshToken)
return &ccloudv1.AuthenticateReply{
Token: newToken,
RefreshToken: newRefreshToken,
}, nil
},
}
client := &ccloudv1.Client{Auth: auth}

err := ctx.SwitchOrganization(client, newOrgId)
require.NoError(t, err)

require.Equal(t, newOrgId, ctx.LastOrgId)
require.Equal(t, newToken, ctx.State.AuthToken)
require.Equal(t, newRefreshToken, ctx.State.AuthRefreshToken)
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new SwitchOrganization unit tests assert token/org ID changes and that environment/Kafka maps are cleared, but they don’t verify that org-scoped auth metadata is updated (e.g., ctx.State.Auth.Organization.ResourceId) or cleared. Adding assertions for this would prevent regressions where the context switches org IDs but still reports/uses the previous organization in suspension checks or other metadata paths.

Suggested change
require.Equal(t, newRefreshToken, ctx.State.AuthRefreshToken)
require.Equal(t, newRefreshToken, ctx.State.AuthRefreshToken)
// Ensure org-scoped auth metadata is updated to reflect the new organization.
require.NotNil(t, ctx.State.Auth)
require.NotNil(t, ctx.State.Auth.Organization)
require.Equal(t, newOrgId, ctx.State.Auth.Organization.ResourceId)

Copilot uses AI. Check for mistakes.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as this comment : #3277 (comment)

}

func TestSwitchOrganization_ClearsEnvironmentState(t *testing.T) {

Check warning on line 115 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Rename function "TestSwitchOrganization_ClearsEnvironmentState" to match the regular expression ^(_|[a-zA-Z0-9]+)$

[S100] Function names should comply with a naming convention See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=f736780c-f534-4310-a5d6-42bc00fb99b7&open=f736780c-f534-4310-a5d6-42bc00fb99b7
cfg := AuthenticatedCloudConfigMock()
ctx := cfg.Context()
ctx.State.AuthRefreshToken = "refresh-token"

// Pre-populate environment and Kafka state
ctx.CurrentEnvironment = "env-123"

Check failure on line 121 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "env-123" 7 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=180e1319-044e-4a78-9ca8-beabc8edbdd1&open=180e1319-044e-4a78-9ca8-beabc8edbdd1
ctx.Environments["env-123"] = &EnvironmentContext{CurrentFlinkComputePool: "pool-1"}
ctx.KafkaClusterContext.KafkaEnvContexts["env-123"] = &KafkaEnvContext{
ActiveKafkaCluster: "lkc-999",

Check failure on line 124 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "lkc-999" 3 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=51095a1e-1836-4ff3-91a4-e5f7dfdad2af&open=51095a1e-1836-4ff3-91a4-e5f7dfdad2af
KafkaClusterConfigs: map[string]*KafkaClusterConfig{"lkc-999": {ID: "lkc-999"}},
}

auth := &ccloudv1mock.Auth{
LoginFunc: func(_ *ccloudv1.AuthenticateRequest) (*ccloudv1.AuthenticateReply, error) {
return &ccloudv1.AuthenticateReply{Token: "t", RefreshToken: "r"}, nil
},
}
client := &ccloudv1.Client{Auth: auth}

err := ctx.SwitchOrganization(client, "org-other")

Check failure on line 135 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Define a constant instead of duplicating this literal "org-other" 4 times.

[S1192] String literals should not be duplicated See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=699ad6c1-b6ad-44ef-b45e-6eef8841f869&open=699ad6c1-b6ad-44ef-b45e-6eef8841f869
require.NoError(t, err)

require.Empty(t, ctx.CurrentEnvironment)
require.Empty(t, ctx.Environments)
require.Empty(t, ctx.KafkaClusterContext.ActiveKafkaCluster)
require.Empty(t, ctx.KafkaClusterContext.ActiveKafkaClusterEndpoint)
require.Empty(t, ctx.KafkaClusterContext.KafkaClusterConfigs)
require.Empty(t, ctx.KafkaClusterContext.KafkaEnvContexts)
}

func TestSwitchOrganization_RollbackOnFailure(t *testing.T) {

Check warning on line 146 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Rename function "TestSwitchOrganization_RollbackOnFailure" to match the regular expression ^(_|[a-zA-Z0-9]+)$

[S100] Function names should comply with a naming convention See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=17df5a75-f97a-4b7d-ba82-f4fc2db6320f&open=17df5a75-f97a-4b7d-ba82-f4fc2db6320f
cfg := AuthenticatedCloudConfigMock()
ctx := cfg.Context()

oldOrgId := ctx.LastOrgId
oldToken := ctx.State.AuthToken
oldRefreshToken := "old-refresh-token"
ctx.State.AuthRefreshToken = oldRefreshToken

// Pre-populate environment state to verify it is NOT cleared on failure
ctx.CurrentEnvironment = "env-123"
ctx.Environments["env-123"] = &EnvironmentContext{}

auth := &ccloudv1mock.Auth{
LoginFunc: func(_ *ccloudv1.AuthenticateRequest) (*ccloudv1.AuthenticateReply, error) {
return nil, fmt.Errorf("auth failed")
},
}
client := &ccloudv1.Client{Auth: auth}

err := ctx.SwitchOrganization(client, "org-other")
require.Error(t, err)
require.Contains(t, err.Error(), "auth failed")

// Verify full rollback
require.Equal(t, oldOrgId, ctx.LastOrgId)
require.Equal(t, oldToken, ctx.State.AuthToken)
require.Equal(t, oldRefreshToken, ctx.State.AuthRefreshToken)

// Verify environment state was NOT touched
require.Equal(t, "env-123", ctx.CurrentEnvironment)
require.Contains(t, ctx.Environments, "env-123")
}

func TestSwitchOrganization_NilKafkaClusterContext(t *testing.T) {

Check warning on line 180 in pkg/config/context_test.go

View check run for this annotation

SonarQube-Confluent / SonarQube Code Analysis

Rename function "TestSwitchOrganization_NilKafkaClusterContext" to match the regular expression ^(_|[a-zA-Z0-9]+)$

[S100] Function names should comply with a naming convention See more on https://sonarqube.confluent.io/project/issues?id=cli&pullRequest=3277&issues=d4c412d0-3258-463e-8ee4-0484f339ea32&open=d4c412d0-3258-463e-8ee4-0484f339ea32
cfg := AuthenticatedCloudConfigMock()
ctx := cfg.Context()
ctx.State.AuthRefreshToken = "refresh-token"
ctx.KafkaClusterContext = nil

auth := &ccloudv1mock.Auth{
LoginFunc: func(_ *ccloudv1.AuthenticateRequest) (*ccloudv1.AuthenticateReply, error) {
return &ccloudv1.AuthenticateReply{Token: "t", RefreshToken: "r"}, nil
},
}
client := &ccloudv1.Client{Auth: auth}

err := ctx.SwitchOrganization(client, "org-other")
require.NoError(t, err)

require.Equal(t, "org-other", ctx.LastOrgId)
require.Empty(t, ctx.CurrentEnvironment)
require.Nil(t, ctx.KafkaClusterContext)
}

func getBaseContext() *Context {
return AuthenticatedCloudConfigMock().Context()
}
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/output/organization/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Available Commands:
describe Describe the current Confluent Cloud organization.
list List Confluent Cloud organizations.
update Update the current Confluent Cloud organization.
use Use a Confluent Cloud organization in subsequent commands.

Global Flags:
-h, --help Show help for this command.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Using organization "abc-123".
9 changes: 9 additions & 0 deletions test/fixtures/output/organization/use-help.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Choose a Confluent Cloud organization to be used in subsequent commands. Switching organizations clears your active environment and Kafka cluster selections.

Usage:
confluent organization use <id> [flags]

Global Flags:
-h, --help Show help for this command.
--unsafe-trace Equivalent to -vvvv, but also log HTTP requests and responses which might contain plaintext secrets.
-v, --verbose count Increase verbosity (-v for warn, -vv for info, -vvv for debug, -vvvv for trace).
1 change: 1 addition & 0 deletions test/fixtures/output/organization/use-list-error.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Error: failed to list organizations: Forbidden
4 changes: 4 additions & 0 deletions test/fixtures/output/organization/use-not-found.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Error: organization "org-dne" not found or access forbidden

Suggestions:
List available organizations with `organization list`.
1 change: 1 addition & 0 deletions test/fixtures/output/organization/use.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Using organization "abc-456".
27 changes: 27 additions & 0 deletions test/organization_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func (s *CLITestSuite) TestOrganization() {
{args: "organization list -o json", fixture: "organization/list-json.golden"},
{args: "organization update --name default-updated", fixture: "organization/update.golden"},
{args: "organization update --name default-updated -o json", fixture: "organization/update-json.golden"},
{args: "organization use abc-456", fixture: "organization/use.golden"},
{args: "organization use abc-123", fixture: "organization/use-already-active.golden"},
}

for _, test := range tests {
Expand Down Expand Up @@ -47,4 +49,29 @@ func (s *CLITestSuite) TestOrganization() {
}
s.runIntegrationTest(test)
})

s.T().Run("organization use list error", func(t *testing.T) {
testserver.TestOrgListError = true
defer func() { testserver.TestOrgListError = false }()

test := CLITest{
args: "organization use abc-456",
fixture: "organization/use-list-error.golden",
exitCode: 1,
login: "cloud",
}
s.runIntegrationTest(test)
})

// The use command validates via the list endpoint, so org-dne simply
// won't appear in the returned list — no need for TestOrgNotFound.
s.T().Run("organization use not found", func(t *testing.T) {
test := CLITest{
args: "organization use org-dne",
fixture: "organization/use-not-found.golden",
exitCode: 1,
login: "cloud",
}
s.runIntegrationTest(test)
})
}
11 changes: 11 additions & 0 deletions test/test-server/org_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ var (
}
// Test flag to control organization not found error
TestOrgNotFound = false
// Test flag to control organization list error
TestOrgListError = false
)

// Handler for: "/org/v2/environments/{id}"
Expand Down Expand Up @@ -145,6 +147,15 @@ func handleOrgOrganizations(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
if TestOrgListError {
w.WriteHeader(http.StatusForbidden)
resp := errors.ErrorResponseBody{Errors: []errors.ErrorDetail{
{Detail: "Forbidden"},
}}
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
return
}
organizationList := &orgv2.OrgV2OrganizationList{Data: []orgv2.OrgV2Organization{
{Id: orgv2.PtrString("abc-123"), DisplayName: orgv2.PtrString("org1"), JitEnabled: orgv2.PtrBool(true)},
{Id: orgv2.PtrString("abc-456"), DisplayName: orgv2.PtrString("org2"), JitEnabled: orgv2.PtrBool(true)},
Expand Down