Skip to content

Commit 3f86537

Browse files
committed
feat(credentials): add in-place credential rotation via upsert
Introduces WithSecretName functional option to SaveCredentials, allowing callers to provide an existing secret path for in-place upsert instead of always generating a new UUID-based path. Updates CAS backend Update() to pass the existing secret name on rotation, eliminating orphaned credentials in all supported secret managers (Vault, AWS, GCP, Azure Key Vault). Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent 3cfc998 commit 3f86537

14 files changed

Lines changed: 591 additions & 90 deletions

File tree

app/controlplane/pkg/biz/casbackend.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -429,7 +429,11 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id string, descr
429429
credentialsUpdated := creds != nil
430430
// We want to rotate credentials
431431
if creds != nil {
432-
secretName, err = uc.credsRW.SaveCredentials(ctx, orgID, creds)
432+
var saveOpts []credentials.SaveOption
433+
if before.SecretName != "" {
434+
saveOpts = append(saveOpts, credentials.WithSecretName(before.SecretName))
435+
}
436+
secretName, err = uc.credsRW.SaveCredentials(ctx, orgID, creds, saveOpts...)
433437
if err != nil {
434438
return nil, fmt.Errorf("storing the credentials: %w", err)
435439
}

app/controlplane/pkg/biz/casbackend_test.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2024-2025 The Chainloop Authors.
2+
// Copyright 2024-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -264,6 +264,93 @@ func (s *casBackendTestSuite) TestNewCASBackendUseCase() {
264264
}
265265
}
266266

267+
// TestUpdateRotatesCredentialsInPlace verifies that Update() passes the existing SecretName
268+
// as a WithSecretName option so the credential store upserts in-place instead of
269+
// creating a new entry.
270+
func (s *casBackendTestSuite) TestUpdateRotatesCredentialsInPlace() {
271+
ctx := context.Background()
272+
existingSecretName := "org/existing-secret-path"
273+
backendID := uuid.New()
274+
newCreds := &credentials.OCIKeypair{Repo: "r", Username: "u", Password: "p"}
275+
276+
tests := []struct {
277+
name string
278+
existingSecret string
279+
wantWithSecretName bool // whether the SaveOption should carry the existing secret name
280+
returnedSecretName string // what SaveCredentials mock returns
281+
}{
282+
{
283+
name: "existing secret name is forwarded as WithSecretName",
284+
existingSecret: existingSecretName,
285+
wantWithSecretName: true,
286+
returnedSecretName: existingSecretName,
287+
},
288+
{
289+
name: "empty secret name generates a new path",
290+
existingSecret: "",
291+
wantWithSecretName: false,
292+
returnedSecretName: "new-secret-path",
293+
},
294+
}
295+
296+
for _, tc := range tests {
297+
s.Run(tc.name, func() {
298+
s.resetMock()
299+
300+
before := &biz.CASBackend{
301+
ID: backendID,
302+
SecretName: tc.existingSecret,
303+
Provider: backendType,
304+
}
305+
306+
// Step 1: FindByIDInOrg returns the existing backend (consumed once).
307+
s.repo.On("FindByIDInOrg", ctx, s.validUUID, backendID).Return(before, nil).Once()
308+
309+
// Step 2: SaveCredentials — capture SaveOption(s) to verify the secret name.
310+
var capturedSecretName string
311+
saveMatcher := mock.MatchedBy(func(_ interface{}) bool { return true })
312+
s.credsRW.On("SaveCredentials", ctx, s.validUUID.String(), saveMatcher, mock.Anything).
313+
Run(func(args mock.Arguments) {
314+
// args[3] is the first SaveOption when opts is non-empty.
315+
if len(args) > 3 {
316+
if opt, ok := args.Get(3).(credentials.SaveOption); ok {
317+
o := credentials.ApplySaveOptions([]credentials.SaveOption{opt})
318+
capturedSecretName = o.SecretName
319+
}
320+
}
321+
}).Return(tc.returnedSecretName, nil).Maybe()
322+
323+
// Fallback for no-opts case (empty existing secret → no WithSecretName).
324+
s.credsRW.On("SaveCredentials", ctx, s.validUUID.String(), saveMatcher).
325+
Return(tc.returnedSecretName, nil).Maybe()
326+
327+
// Step 3: repo.Update persists the change.
328+
updatedBackend := &biz.CASBackend{ID: backendID, SecretName: tc.returnedSecretName, Provider: backendType}
329+
s.repo.On("Update", ctx, mock.Anything).Return(updatedBackend, nil)
330+
331+
// Steps 4–7: PerformValidation is called internally with new creds.
332+
s.repo.On("FindByID", mock.Anything, backendID).Return(updatedBackend, nil)
333+
s.credsRW.On("ReadCredentials", mock.Anything, mock.Anything, mock.Anything).Return(nil)
334+
s.backendProvider.On("ValidateAndExtractCredentials", mock.Anything, mock.Anything).Return(nil, nil)
335+
s.repo.On("UpdateValidationStatus", mock.Anything, backendID, biz.CASBackendValidationOK, mock.Anything).Return(nil)
336+
337+
// Step 8: FindByIDInOrg reload after validation.
338+
s.repo.On("FindByIDInOrg", ctx, s.validUUID, backendID).Return(updatedBackend, nil)
339+
340+
got, err := s.useCase.Update(ctx, s.validUUID.String(), backendID.String(), nil, newCreds, nil, nil, nil)
341+
s.Require().NoError(err)
342+
s.Equal(tc.returnedSecretName, got.SecretName)
343+
344+
if tc.wantWithSecretName {
345+
s.Equal(existingSecretName, capturedSecretName,
346+
"expected WithSecretName(%q) to be forwarded to SaveCredentials", existingSecretName)
347+
} else {
348+
s.Empty(capturedSecretName, "expected no WithSecretName when existingSecret is empty")
349+
}
350+
})
351+
}
352+
}
353+
267354
// Run all the tests
268355
func TestCASBackend(t *testing.T) {
269356
suite.Run(t, new(casBackendTestSuite))

pkg/credentials/aws/mocks/SecretsManagerIface.go

Lines changed: 38 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/credentials/aws/secretmanager.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/aws/aws-sdk-go-v2/aws"
2727
awscreds "github.com/aws/aws-sdk-go-v2/credentials"
2828
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
29+
smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
2930
"github.com/aws/aws-sdk-go-v2/service/sso/types"
3031
"github.com/aws/smithy-go"
3132
"github.com/chainloop-dev/chainloop/pkg/credentials"
@@ -38,6 +39,7 @@ import (
3839
type SecretsManagerIface interface {
3940
GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error)
4041
CreateSecret(ctx context.Context, params *secretsmanager.CreateSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error)
42+
PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error)
4143
DeleteSecret(ctx context.Context, params *secretsmanager.DeleteSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.DeleteSecretOutput, error)
4244
}
4345

@@ -79,21 +81,43 @@ func NewManager(opts *NewManagerOpts) (*Manager, error) {
7981
}, nil
8082
}
8183

82-
// Save Credentials, this is a generic function that can be used to save any type of credentials
83-
// as long as they can be passed to json.Marshal
84-
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any) (string, error) {
85-
secretName := strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "/")
84+
// SaveCredentials saves credentials. If opts includes WithSecretName, upserts at the given path.
85+
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any, opts ...credentials.SaveOption) (string, error) {
86+
o := credentials.ApplySaveOptions(opts)
87+
secretName := o.SecretName
88+
if secretName == "" {
89+
secretName = strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "/")
90+
}
8691

8792
// Store the credentials as json key pairs
8893
c, err := json.Marshal(creds)
8994
if err != nil {
9095
return "", fmt.Errorf("marshaling credentials to be stored: %w", err)
9196
}
9297

93-
if _, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
94-
Name: aws.String(secretName), SecretString: aws.String(string(c)),
95-
}); err != nil {
96-
return "", fmt.Errorf("creating secret in AWS: %w", err)
98+
if o.SecretName != "" {
99+
// Upsert: try to update existing secret first
100+
_, err = m.client.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
101+
SecretId: aws.String(secretName),
102+
SecretString: aws.String(string(c)),
103+
})
104+
var notFoundErr *smtypes.ResourceNotFoundException
105+
if errors.As(err, &notFoundErr) {
106+
// Secret does not exist yet, create it
107+
_, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
108+
Name: aws.String(secretName),
109+
SecretString: aws.String(string(c)),
110+
})
111+
}
112+
} else {
113+
_, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
114+
Name: aws.String(secretName),
115+
SecretString: aws.String(string(c)),
116+
})
117+
}
118+
119+
if err != nil {
120+
return "", fmt.Errorf("saving secret in AWS: %w", err)
97121
}
98122

99123
return secretName, nil

pkg/credentials/aws/secretmanager_test.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@ import (
2424

2525
"github.com/aws/aws-sdk-go-v2/aws"
2626
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
27+
smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
2728
"github.com/aws/aws-sdk-go-v2/service/sso/types"
2829
"github.com/aws/smithy-go"
2930
"github.com/chainloop-dev/chainloop/pkg/credentials"
@@ -202,6 +203,75 @@ func (s *testSuite) TestReadWriteCredentials() {
202203
}
203204
}
204205

206+
func (s *testSuite) TestSaveCredentialsUpsert() {
207+
assert := assert.New(s.T())
208+
creds := &credentials.OCIKeypair{Repo: "r", Username: "u", Password: "p"}
209+
existingSecretName := "org/existing-secret"
210+
211+
testCases := []struct {
212+
name string
213+
secretName string // non-empty = WithSecretName upsert
214+
awsNotFound bool // simulate ResourceNotFoundException on PutSecretValue
215+
expectedError bool
216+
}{
217+
{
218+
name: "new secret — CreateSecret called",
219+
secretName: "",
220+
},
221+
{
222+
name: "upsert existing — PutSecretValue succeeds",
223+
secretName: existingSecretName,
224+
},
225+
{
226+
name: "upsert not found — fallback to CreateSecret",
227+
secretName: existingSecretName,
228+
awsNotFound: true,
229+
},
230+
}
231+
232+
for _, tc := range testCases {
233+
s.Run(tc.name, func() {
234+
initMockedManager(s)
235+
m := s.mockedManager
236+
mc, _ := m.client.(*mclient.SecretsManagerIface)
237+
ctx := context.Background()
238+
239+
var saveOpts []credentials.SaveOption
240+
if tc.secretName != "" {
241+
saveOpts = append(saveOpts, credentials.WithSecretName(tc.secretName))
242+
}
243+
244+
switch {
245+
case tc.secretName == "":
246+
// New secret path: only CreateSecret is called.
247+
mc.On("CreateSecret", ctx, mock.Anything).Return(nil, nil)
248+
case tc.awsNotFound:
249+
// Upsert path: PutSecretValue returns not-found, then CreateSecret.
250+
mc.On("PutSecretValue", ctx, mock.Anything).Return(nil, &smtypes.ResourceNotFoundException{})
251+
mc.On("CreateSecret", ctx, mock.Anything).Return(nil, nil)
252+
default:
253+
// Upsert path: PutSecretValue succeeds directly.
254+
mc.On("PutSecretValue", ctx, mock.Anything).Return(&secretsmanager.PutSecretValueOutput{}, nil)
255+
}
256+
257+
returned, err := m.SaveCredentials(ctx, orgID, creds, saveOpts...)
258+
if tc.expectedError {
259+
assert.Error(err)
260+
return
261+
}
262+
assert.NoError(err)
263+
264+
if tc.secretName != "" {
265+
// In-place upsert must return the same path.
266+
assert.Equal(tc.secretName, returned)
267+
} else {
268+
// New path must be auto-generated (non-empty and different from the existing one).
269+
assert.NotEmpty(returned)
270+
}
271+
})
272+
}
273+
}
274+
205275
// // Create a new secret, delete it and check it does not exist antymore
206276
func (s *testSuite) TestDeleteCredentials() {
207277
assert := assert.New(s.T())

pkg/credentials/azurekv/keyvault.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright 2023 The Chainloop Authors.
2+
// Copyright 2023-2026 The Chainloop Authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -120,9 +120,14 @@ func NewManager(opts *NewManagerOpts) (*Manager, error) {
120120
}, nil
121121
}
122122

123-
// SaveCredentials saves credentials
124-
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any) (string, error) {
125-
secretName := strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "-")
123+
// SaveCredentials saves credentials. If opts includes WithSecretName, upserts at the given path.
124+
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any, opts ...credentials.SaveOption) (string, error) {
125+
o := credentials.ApplySaveOptions(opts)
126+
secretName := o.SecretName
127+
if secretName == "" {
128+
secretName = strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "-")
129+
}
130+
126131
// Store the credentials as json key pairs
127132
c, err := json.Marshal(creds)
128133
if err != nil {

0 commit comments

Comments
 (0)