Skip to content

Commit 3523157

Browse files
committed
fix(configure): address CodeRabbit findings on SDK migration
Resolve the 7 Major CodeRabbit findings on PR #1279 at root cause. 1. azure sanity: guard sub.State before dereferencing it. State is an optional pointer; build azureSubscriptionInfo with the same nil-check pattern used for the other optional fields, so an omitted state cannot panic. 2. azure wizard: bind to the Azure CLI session explicitly. The wizard now builds credentials via a shared newAzureWizardCredential() backed by azidentity.NewAzureCLICredential, instead of DefaultAzureCredential whose chain prioritizes environment / workload / managed identity and could pick a different principal than the operator's "az login". The CI sanity test keeps DefaultAzureCredential (it must resolve the CI service-principal env vars). listAzureSubscriptions, resolveAzureTenantID and the SP provisioner all use the CLI credential. 3. gcp: bound every SDK helper with a 60s timeout. listGCPProjects, createGCPServiceAccount, grantGCPIAMRole and createGCPServiceAccountKey inherited context.Background() and could hang; each now derives a context.WithTimeout(ctx, gcpSDKCallTimeout). 4. gcp: preserve conditional IAM bindings. GetIamPolicy now requests RequestedPolicyVersion 3 and the policy is written back at Version 3, so a read-modify-write no longer silently drops condition bindings. Binding mutation is extracted into addMemberToPolicyBinding to keep complexity in check. 5. gcp: prevent orphaned service-account keys. createGCPServiceAccountKey reserves the destination file with O_EXCL before minting the remote key, and deletes the newly created remote key if decode/write fails, so a local failure cannot leave an active unused credential behind. 6. gcp: return an empty key path when no key was written. gcpStepCreateKey returns the key file path only after a successful write; on skip or an unknown choice it returns "" so getGCPCredentialsFilePath prompts for an existing credentials file instead of loading a missing one. 7. azure SP: roll back partial creation. createAzureServicePrincipal now deletes the just-created application (cascading to its password credential and derived service principal) if any later step -- add-password, SP create, role resolve, or role assign -- fails. A new DeleteApplication provisioner method backs this; if the compensating delete also fails the error names the orphaned app so the operator can remove it by hand. Unit tests assert rollback fires for each post-create failure, not on success, and that a failed rollback is surfaced.
1 parent e4fb9c5 commit 3523157

4 files changed

Lines changed: 217 additions & 64 deletions

File tree

‎cmd/configure_azure.go‎

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"time"
1818

19+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
1920
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
2021
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
2122
"github.com/aws/aws-sdk-go-v2/aws"
@@ -272,14 +273,29 @@ func promptForAzureCredentialFields(reader *bufio.Reader, creds *AzureCredential
272273
return nil
273274
}
274275

276+
// newAzureWizardCredential builds the credential used by the interactive Azure
277+
// setup wizard. It binds explicitly to the Azure CLI session (the "az login"
278+
// the operator runs in Step 1) via AzureCLICredential rather than
279+
// DefaultAzureCredential, whose chain prioritizes environment / workload /
280+
// managed-identity credentials and could otherwise resolve to a different
281+
// principal than the one the operator just signed in as.
282+
func newAzureWizardCredential() (azcore.TokenCredential, error) {
283+
cred, err := azidentity.NewAzureCLICredential(nil)
284+
if err != nil {
285+
return nil, fmt.Errorf("failed to build Azure CLI credential: %w\n"+
286+
"Ensure you are authenticated: run 'az login' (Step 1) before continuing", err)
287+
}
288+
return cred, nil
289+
}
290+
275291
// listAzureSubscriptions retrieves the operator's subscriptions via the ARM
276292
// Subscriptions SDK and prints them in a table matching "az account list"
277-
// output. DefaultAzureCredential picks up the "az login" session via its
278-
// AzureCLICredential leg so the operator's active session is reused.
293+
// output. It uses the Azure CLI credential so the listing matches the
294+
// operator's active "az login" session.
279295
func listAzureSubscriptions(ctx context.Context) error {
280-
cred, err := azidentity.NewDefaultAzureCredential(nil)
296+
cred, err := newAzureWizardCredential()
281297
if err != nil {
282-
return fmt.Errorf("failed to build credential: %w", err)
298+
return err
283299
}
284300

285301
client, err := armsubscriptions.NewClient(cred, nil)
@@ -322,10 +338,9 @@ func listAzureSubscriptions(ctx context.Context) error {
322338
// SDK on behalf of a human operator who does not yet have a credential. This
323339
// is the only CLI call retained in this wizard.
324340
//
325-
// Step 2 (list subscriptions): performed via the ARM Subscriptions SDK.
326-
// DefaultAzureCredential automatically picks up the session that "az login"
327-
// just established (via its AzureCLICredential leg). Fails loud if the SDK
328-
// cannot authenticate (no CLI fallback).
341+
// Step 2 (list subscriptions): performed via the ARM Subscriptions SDK using
342+
// the Azure CLI credential, which reuses the session that "az login" just
343+
// established. Fails loud if the SDK cannot authenticate (no CLI fallback).
329344
//
330345
// Step 3 (create service principal): performed via the Microsoft Graph SDK
331346
// (application + service principal + password credential) and armauthorization
@@ -356,13 +371,13 @@ func azureStepLogin(reader *bufio.Reader) error {
356371

357372
// azureStepListSubscriptions lists subscriptions via SDK and prompts the
358373
// operator to enter their subscription ID. It fails loud (no CLI fallback): if
359-
// DefaultAzureCredential cannot authenticate, it returns an error instructing
360-
// the operator to run "az login" first.
374+
// the Azure CLI credential cannot authenticate, it returns an error
375+
// instructing the operator to run "az login" first.
361376
func azureStepListSubscriptions(ctx context.Context, reader *bufio.Reader) (string, error) {
362377
fmt.Println()
363378
fmt.Println("Step 2: Get Subscription ID")
364379
fmt.Println("---------------------------")
365-
fmt.Println("Listing your Azure subscriptions via SDK (DefaultAzureCredential)...")
380+
fmt.Println("Listing your Azure subscriptions via SDK (Azure CLI credential)...")
366381
fmt.Println()
367382

368383
listCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
@@ -390,12 +405,11 @@ func azureStepListSubscriptions(ctx context.Context, reader *bufio.Reader) (stri
390405
}
391406

392407
// resolveAzureTenantID looks up the tenant ID for the given subscription via
393-
// the ARM Subscriptions SDK. DefaultAzureCredential reuses the "az login"
394-
// session.
408+
// the ARM Subscriptions SDK, using the Azure CLI ("az login") credential.
395409
func resolveAzureTenantID(ctx context.Context, subscriptionID string) (string, error) {
396-
cred, err := azidentity.NewDefaultAzureCredential(nil)
410+
cred, err := newAzureWizardCredential()
397411
if err != nil {
398-
return "", fmt.Errorf("failed to build credential: %w", err)
412+
return "", err
399413
}
400414
client, err := armsubscriptions.NewClient(cred, nil)
401415
if err != nil {

‎cmd/configure_azure_sp.go‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package main
33
import (
44
"context"
55
"fmt"
6+
"time"
67

7-
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
88
armauthorization "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
99
"github.com/google/uuid"
1010
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
@@ -52,6 +52,11 @@ type azureSPProvisioner interface {
5252
// AssignRole creates a role assignment binding principalID to
5353
// roleDefinitionID at the given scope.
5454
AssignRole(ctx context.Context, scope, principalID, roleDefinitionID string) error
55+
// DeleteApplication deletes the application registration identified by
56+
// objectID. Deleting the application also removes its password credentials
57+
// and the service principal created from it, so it serves as the
58+
// compensating action for a partially completed creation flow.
59+
DeleteApplication(ctx context.Context, objectID string) error
5560
}
5661

5762
// createAzureServicePrincipal performs the full create-for-rbac equivalent:
@@ -73,23 +78,38 @@ func createAzureServicePrincipal(ctx context.Context, p azureSPProvisioner, subs
7378
return azureSPResult{}, fmt.Errorf("failed to create application registration: %w", err)
7479
}
7580

81+
// rollback deletes the just-created application (which cascades to its
82+
// password credentials and the derived service principal) so a failure in
83+
// a later step does not orphan Azure AD objects. The cleanup runs on a
84+
// fresh context in case the parent is already canceled/expired. If the
85+
// cleanup itself fails, the operator is told exactly what to delete by hand.
86+
rollback := func(cause error) (azureSPResult, error) {
87+
cleanupCtx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
88+
defer cancel()
89+
if delErr := p.DeleteApplication(cleanupCtx, objectID); delErr != nil {
90+
return azureSPResult{}, fmt.Errorf("%w; additionally failed to roll back application %q (appId %s) -- delete it manually: %w",
91+
cause, objectID, appID, delErr)
92+
}
93+
return azureSPResult{}, fmt.Errorf("%w (rolled back: deleted application %q)", cause, objectID)
94+
}
95+
7696
secret, err := p.AddPassword(ctx, objectID)
7797
if err != nil {
78-
return azureSPResult{}, fmt.Errorf("failed to add password credential: %w", err)
98+
return rollback(fmt.Errorf("failed to add password credential: %w", err))
7999
}
80100

81101
principalID, err := p.CreateServicePrincipal(ctx, appID)
82102
if err != nil {
83-
return azureSPResult{}, fmt.Errorf("failed to create service principal: %w", err)
103+
return rollback(fmt.Errorf("failed to create service principal: %w", err))
84104
}
85105

86106
roleDefID, err := p.ResolveRoleDefinitionID(ctx, scope, azureSPRoleName)
87107
if err != nil {
88-
return azureSPResult{}, fmt.Errorf("failed to resolve %q role definition: %w", azureSPRoleName, err)
108+
return rollback(fmt.Errorf("failed to resolve %q role definition: %w", azureSPRoleName, err))
89109
}
90110

91111
if err := p.AssignRole(ctx, scope, principalID, roleDefID); err != nil {
92-
return azureSPResult{}, fmt.Errorf("failed to assign %q role at %s: %w", azureSPRoleName, scope, err)
112+
return rollback(fmt.Errorf("failed to assign %q role at %s: %w", azureSPRoleName, scope, err))
93113
}
94114

95115
return azureSPResult{
@@ -108,15 +128,15 @@ type graphSPProvisioner struct {
108128
roleAsgn *armauthorization.RoleAssignmentsClient
109129
}
110130

111-
// newGraphSPProvisioner builds a graphSPProvisioner authenticated with
112-
// DefaultAzureCredential. The credential chain includes AzureCLICredential, so
113-
// the session established by "az login" is reused (no separate auth step).
131+
// newGraphSPProvisioner builds a graphSPProvisioner authenticated with the
132+
// Azure CLI credential, so the session established by "az login" (wizard
133+
// Step 1) is reused -- matching the principal used by the rest of the wizard.
114134
// subscriptionID seeds the RoleAssignmentsClient; the actual scope is passed
115135
// per-call to its Create method.
116136
func newGraphSPProvisioner(subscriptionID string) (*graphSPProvisioner, error) {
117-
cred, err := azidentity.NewDefaultAzureCredential(nil)
137+
cred, err := newAzureWizardCredential()
118138
if err != nil {
119-
return nil, fmt.Errorf("failed to build DefaultAzureCredential: %w", err)
139+
return nil, err
120140
}
121141

122142
graph, err := msgraphsdk.NewGraphServiceClientWithCredentials(
@@ -187,6 +207,10 @@ func (g *graphSPProvisioner) CreateServicePrincipal(ctx context.Context, appID s
187207
return *principalID, nil
188208
}
189209

210+
func (g *graphSPProvisioner) DeleteApplication(ctx context.Context, objectID string) error {
211+
return g.graph.Applications().ByApplicationId(objectID).Delete(ctx, nil)
212+
}
213+
190214
func (g *graphSPProvisioner) ResolveRoleDefinitionID(ctx context.Context, scope, roleName string) (string, error) {
191215
filter := fmt.Sprintf("roleName eq '%s'", roleName)
192216
pager := g.roleDefs.NewListPager(scope, &armauthorization.RoleDefinitionsClientListOptions{

‎cmd/configure_azure_sp_test.go‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import (
1313
// createAzureServicePrincipal requests the correct app name, role and scope,
1414
// and surfaces the generated secret.
1515
type mockSPProvisioner struct {
16+
// optional injected errors
17+
createAppErr error
18+
addPwErr error
19+
createSPErr error
20+
resolveErr error
21+
assignErr error
22+
deleteAppErr error
23+
1624
// captured inputs
1725
createAppName string
1826
addPasswordObjID string
@@ -22,6 +30,7 @@ type mockSPProvisioner struct {
2230
assignScope string
2331
assignPrincipalID string
2432
assignRoleDefID string
33+
deleteAppObjID string
2534

2635
// canned outputs
2736
appObjectID string
@@ -30,16 +39,10 @@ type mockSPProvisioner struct {
3039
principalID string
3140
roleDefID string
3241

33-
// optional injected errors
34-
createAppErr error
35-
addPwErr error
36-
createSPErr error
37-
resolveErr error
38-
assignErr error
39-
4042
// call flags
4143
resolveRoleCalled bool
4244
assignRoleCalled bool
45+
deleteAppCalled bool
4346
}
4447

4548
func (m *mockSPProvisioner) CreateApplication(_ context.Context, displayName string) (string, string, error) {
@@ -84,6 +87,12 @@ func (m *mockSPProvisioner) AssignRole(_ context.Context, scope, principalID, ro
8487
return m.assignErr
8588
}
8689

90+
func (m *mockSPProvisioner) DeleteApplication(_ context.Context, objectID string) error {
91+
m.deleteAppCalled = true
92+
m.deleteAppObjID = objectID
93+
return m.deleteAppErr
94+
}
95+
8796
const (
8897
testSubID = "11111111-2222-3333-4444-555555555555"
8998
testTenantID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
@@ -127,6 +136,9 @@ func TestCreateAzureServicePrincipal_Success(t *testing.T) {
127136
assert.Equal(t, "app-client-id", result.AppID)
128137
assert.Equal(t, "super-secret-password", result.ClientSecret)
129138
assert.Equal(t, testTenantID, result.TenantID)
139+
140+
// No rollback on success.
141+
assert.False(t, m.deleteAppCalled, "DeleteApplication must not be called on success")
130142
}
131143

132144
func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
@@ -136,38 +148,44 @@ func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
136148
wantErrPart string
137149
wantNoAssign bool
138150
wantNoResolve bool
151+
wantRollback bool // DeleteApplication should be called to clean up
139152
}{
140153
{
141154
name: "create application fails",
142155
setup: func(m *mockSPProvisioner) { m.createAppErr = errors.New("graph 403") },
143156
wantErrPart: "failed to create application registration",
144157
wantNoAssign: true,
145158
wantNoResolve: true,
159+
wantRollback: false, // nothing was created, nothing to roll back
146160
},
147161
{
148162
name: "add password fails",
149163
setup: func(m *mockSPProvisioner) { m.addPwErr = errors.New("graph addPassword 400") },
150164
wantErrPart: "failed to add password credential",
151165
wantNoAssign: true,
152166
wantNoResolve: true,
167+
wantRollback: true,
153168
},
154169
{
155170
name: "create service principal fails",
156171
setup: func(m *mockSPProvisioner) { m.createSPErr = errors.New("graph sp 409") },
157172
wantErrPart: "failed to create service principal",
158173
wantNoAssign: true,
159174
wantNoResolve: true,
175+
wantRollback: true,
160176
},
161177
{
162178
name: "resolve role fails",
163179
setup: func(m *mockSPProvisioner) { m.resolveErr = errors.New("role not found") },
164180
wantErrPart: "failed to resolve",
165181
wantNoAssign: true,
182+
wantRollback: true,
166183
},
167184
{
168-
name: "assign role fails",
169-
setup: func(m *mockSPProvisioner) { m.assignErr = errors.New("rbac 403") },
170-
wantErrPart: "failed to assign",
185+
name: "assign role fails",
186+
setup: func(m *mockSPProvisioner) { m.assignErr = errors.New("rbac 403") },
187+
wantErrPart: "failed to assign",
188+
wantRollback: true,
171189
},
172190
}
173191

@@ -192,10 +210,38 @@ func TestCreateAzureServicePrincipal_ErrorPropagation(t *testing.T) {
192210
if tt.wantNoAssign {
193211
assert.False(t, m.assignRoleCalled, "AssignRole should not have been called")
194212
}
213+
assert.Equal(t, tt.wantRollback, m.deleteAppCalled,
214+
"DeleteApplication call expectation mismatch")
215+
if tt.wantRollback {
216+
assert.Equal(t, "app-object-id", m.deleteAppObjID,
217+
"rollback should delete the created application by object ID")
218+
}
195219
})
196220
}
197221
}
198222

223+
// TestCreateAzureServicePrincipal_RollbackFailureSurfaced verifies that when
224+
// the compensating delete also fails, the error names the orphaned application
225+
// so the operator can delete it manually.
226+
func TestCreateAzureServicePrincipal_RollbackFailureSurfaced(t *testing.T) {
227+
m := &mockSPProvisioner{
228+
appObjectID: "app-object-id",
229+
appID: "app-client-id",
230+
secret: "secret",
231+
principalID: "sp-principal-id",
232+
roleDefID: "role-def-id",
233+
assignErr: errors.New("rbac 403"),
234+
deleteAppErr: errors.New("delete 500"),
235+
}
236+
237+
_, err := createAzureServicePrincipal(context.Background(), m, testSubID, testTenantID)
238+
require.Error(t, err)
239+
assert.True(t, m.deleteAppCalled)
240+
assert.Contains(t, err.Error(), "failed to assign")
241+
assert.Contains(t, err.Error(), "failed to roll back")
242+
assert.Contains(t, err.Error(), "app-object-id")
243+
}
244+
199245
func TestCreateAzureServicePrincipal_ScopeFormat(t *testing.T) {
200246
m := &mockSPProvisioner{
201247
appObjectID: "o", appID: "a", secret: "s", principalID: "p", roleDefID: "r",

0 commit comments

Comments
 (0)