Skip to content
Merged
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
8 changes: 6 additions & 2 deletions app/controlplane/pkg/biz/casbackend.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -429,7 +429,11 @@ func (uc *CASBackendUseCase) Update(ctx context.Context, orgID, id string, descr
credentialsUpdated := creds != nil
// We want to rotate credentials
if creds != nil {
secretName, err = uc.credsRW.SaveCredentials(ctx, orgID, creds)
var saveOpts []credentials.SaveOption
if before.SecretName != "" {
saveOpts = append(saveOpts, credentials.WithExistingSecret(before.SecretName))
}
secretName, err = uc.credsRW.SaveCredentials(ctx, orgID, creds, saveOpts...)
if err != nil {
return nil, fmt.Errorf("storing the credentials: %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions app/controlplane/pkg/biz/casbackend_integration_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -410,7 +410,7 @@ func (s *CASBackendIntegrationTestSuite) TestUpdate() {
creds := struct{}{}
ctx := context.TODO()
s.credsWriter.Mock = mock.Mock{}
s.credsWriter.On("SaveCredentials", ctx, s.orgNoBackend.ID, creds).Return("new-secret", nil)
s.credsWriter.On("SaveCredentials", ctx, s.orgNoBackend.ID, creds, mock.Anything).Return("new-secret", nil)
s.credsWriter.On("ReadCredentials", ctx, "new-secret", mock.Anything).Return(nil)
s.backendProvider.On("ValidateAndExtractCredentials", location, mock.Anything).Return(nil, nil)
defaultB, err = s.CASBackend.Update(ctx, s.orgNoBackend.ID, defaultB.ID.String(), toPtrS(description), creds, nil, nil, nil)
Expand Down
91 changes: 90 additions & 1 deletion app/controlplane/pkg/biz/casbackend_test.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024-2025 The Chainloop Authors.
// Copyright 2024-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -264,6 +264,95 @@ func (s *casBackendTestSuite) TestNewCASBackendUseCase() {
}
}

// TestUpdateRotatesCredentialsInPlace verifies that Update() passes the existing SecretName
// as a WithExistingSecret option so the credential store upserts in-place instead of
// creating a new entry.
func (s *casBackendTestSuite) TestUpdateRotatesCredentialsInPlace() {
ctx := context.Background()
existingSecretName := "org/existing-secret-path"
backendID := uuid.New()
newCreds := &credentials.OCIKeypair{Repo: "r", Username: "u", Password: "p"}

tests := []struct {
name string
existingSecret string
wantWithExistingSecret bool // whether the SaveOption should carry the existing secret name
returnedSecretName string // what SaveCredentials mock returns
}{
{
name: "existing secret name is forwarded as WithExistingSecret",
existingSecret: existingSecretName,
wantWithExistingSecret: true,
returnedSecretName: existingSecretName,
},
{
name: "empty secret name generates a new path",
existingSecret: "",
wantWithExistingSecret: false,
returnedSecretName: "new-secret-path",
},
}

for _, tc := range tests {
s.Run(tc.name, func() {
s.resetMock()

before := &biz.CASBackend{
ID: backendID,
SecretName: tc.existingSecret,
Provider: backendType,
}

// Step 1: FindByIDInOrg returns the existing backend (consumed once).
s.repo.On("FindByIDInOrg", ctx, s.validUUID, backendID).Return(before, nil).Once()

// Step 2: SaveCredentials — capture SaveOption(s) to verify the secret name.
var capturedSecretName string
var optWasPassed bool
saveMatcher := mock.MatchedBy(func(_ interface{}) bool { return true })
s.credsRW.On("SaveCredentials", ctx, s.validUUID.String(), saveMatcher, mock.Anything).
Run(func(args mock.Arguments) {
if len(args) > 3 {
optWasPassed = true
if opt, ok := args.Get(3).(credentials.SaveOption); ok {
o := credentials.ApplySaveOptions(opt)
capturedSecretName = o.SecretName
}
}
}).Return(tc.returnedSecretName, nil).Maybe()

// Fallback for no-opts case (empty existing secret → no WithExistingSecret).
s.credsRW.On("SaveCredentials", ctx, s.validUUID.String(), saveMatcher).
Return(tc.returnedSecretName, nil).Maybe()

// Step 3: repo.Update persists the change.
updatedBackend := &biz.CASBackend{ID: backendID, SecretName: tc.returnedSecretName, Provider: backendType}
s.repo.On("Update", ctx, mock.Anything).Return(updatedBackend, nil)

// Steps 4–7: PerformValidation is called internally with new creds.
s.repo.On("FindByID", mock.Anything, backendID).Return(updatedBackend, nil)
s.credsRW.On("ReadCredentials", mock.Anything, mock.Anything, mock.Anything).Return(nil)
s.backendProvider.On("ValidateAndExtractCredentials", mock.Anything, mock.Anything).Return(nil, nil)
s.repo.On("UpdateValidationStatus", mock.Anything, backendID, biz.CASBackendValidationOK, mock.Anything).Return(nil)

// Step 8: FindByIDInOrg reload after validation.
s.repo.On("FindByIDInOrg", ctx, s.validUUID, backendID).Return(updatedBackend, nil)

got, err := s.useCase.Update(ctx, s.validUUID.String(), backendID.String(), nil, newCreds, nil, nil, nil)
s.Require().NoError(err)
s.Equal(tc.returnedSecretName, got.SecretName)

if tc.wantWithExistingSecret {
s.True(optWasPassed, "expected SaveOption to be passed to SaveCredentials")
s.Equal(existingSecretName, capturedSecretName,
"expected WithExistingSecret(%q) to be forwarded to SaveCredentials", existingSecretName)
} else {
s.False(optWasPassed, "expected no SaveOption to be passed to SaveCredentials")
}
})
}
}

// Run all the tests
func TestCASBackend(t *testing.T) {
suite.Run(t, new(casBackendTestSuite))
Expand Down
26 changes: 26 additions & 0 deletions pkg/credentials/.mockery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
all: false
formatter: goimports
include-auto-generated: false
log-level: info
recursive: false
require-template-schema-exists: true
template: testify
template-schema: "{{.Template}}.schema.json"
packages:
github.com/chainloop-dev/chainloop/pkg/credentials:
config:
dir: "{{.InterfaceDir}}/mocks"
filename: "{{.InterfaceName}}.go"
pkgname: mocks
structname: "{{.InterfaceName}}"
interfaces:
ReaderWriter:
Writer:
github.com/chainloop-dev/chainloop/pkg/credentials/aws:
config:
dir: "{{.InterfaceDir}}/mocks"
filename: "{{.InterfaceName}}.go"
pkgname: mocks
structname: "{{.InterfaceName}}"
interfaces:
SecretsManagerIface:
46 changes: 38 additions & 8 deletions pkg/credentials/aws/mocks/SecretsManagerIface.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 33 additions & 9 deletions pkg/credentials/aws/secretmanager.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2023-2025 The Chainloop Authors.
// Copyright 2023-2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -26,6 +26,7 @@ import (
"github.com/aws/aws-sdk-go-v2/aws"
awscreds "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
smtypes "github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
"github.com/aws/aws-sdk-go-v2/service/sso/types"
"github.com/aws/smithy-go"
"github.com/chainloop-dev/chainloop/pkg/credentials"
Expand All @@ -38,6 +39,7 @@ import (
type SecretsManagerIface interface {
GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error)
CreateSecret(ctx context.Context, params *secretsmanager.CreateSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.CreateSecretOutput, error)
PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error)
DeleteSecret(ctx context.Context, params *secretsmanager.DeleteSecretInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.DeleteSecretOutput, error)
}

Expand Down Expand Up @@ -79,21 +81,43 @@ func NewManager(opts *NewManagerOpts) (*Manager, error) {
}, nil
}

// Save Credentials, this is a generic function that can be used to save any type of credentials
// as long as they can be passed to json.Marshal
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any) (string, error) {
secretName := strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "/")
// SaveCredentials saves credentials. If opts includes WithExistingSecret, upserts at the given path.
func (m *Manager) SaveCredentials(ctx context.Context, orgID string, creds any, opts ...credentials.SaveOption) (string, error) {
o := credentials.ApplySaveOptions(opts...)
secretName := o.SecretName
Comment thread
migmartri marked this conversation as resolved.
if secretName == "" {
secretName = strings.Join([]string{m.secretPrefix, orgID, uuid.New().String()}, "/")
}

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

if _, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: aws.String(secretName), SecretString: aws.String(string(c)),
}); err != nil {
return "", fmt.Errorf("creating secret in AWS: %w", err)
if o.SecretName != "" {
// Upsert: try to update existing secret first
_, err = m.client.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
SecretId: aws.String(secretName),
SecretString: aws.String(string(c)),
})
var notFoundErr *smtypes.ResourceNotFoundException
if errors.As(err, &notFoundErr) {
// Secret does not exist yet, create it
_, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: aws.String(secretName),
SecretString: aws.String(string(c)),
})
}
} else {
_, err = m.client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: aws.String(secretName),
SecretString: aws.String(string(c)),
})
}

if err != nil {
return "", fmt.Errorf("saving secret in AWS: %w", err)
}

return secretName, nil
Expand Down
Loading
Loading