Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add optional capability to seed service secrets #276

Merged
merged 9 commits into from
Sep 28, 2021
Merged
Show file tree
Hide file tree
Changes from 8 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
13 changes: 13 additions & 0 deletions bootstrap/secret/secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ func NewSecretProvider(
secretClient, err = secrets.NewSecretsClient(ctx, secretConfig, lc, secureProvider.DefaultTokenExpiredCallback)
if err == nil {
secureProvider.SetClient(secretClient)

lc.Debugf("SecretsFile is '%s'", secretConfig.SecretsFile)

if len(strings.TrimSpace(secretConfig.SecretsFile)) > 0 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

The code would be less complex if this was changed to if len == 0.

Copy link
Member Author

Choose a reason for hiding this comment

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

How? still an if/else just reversing the logic.

Copy link
Collaborator

@bnevis-i bnevis-i Sep 27, 2021

Choose a reason for hiding this comment

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

  secureProvider.SetClient(secretClient)
  lc.Debugf("SecretsFile is '%s'", secretConfig.SecretsFile)
  if len(strings.TrimSpace(secretConfig.SecretsFile)) > 0 {
    err = secureProvider.LoadServiceSecrets(secretStoreConfig)
    if err != nil {
      return nil, err
    }
  } else {
    lc.Infof("SecretsFile not set, skipping seeding of service secrets.")
  }
  provider = secureProvider


  secureProvider.SetClient(secretClient)
  lc.Debugf("SecretsFile is '%s'", secretConfig.SecretsFile)
  if len(strings.TrimSpace(secretConfig.SecretsFile)) == 0 {
    lc.Infof("SecretsFile not set, skipping seeding of service secrets.")
    break
  }
  err = secureProvider.LoadServiceSecrets(secretStoreConfig)
  if err != nil {
    return nil, err
  }
  provider = secureProvider

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep! :-)

Copy link
Member Author

Choose a reason for hiding this comment

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

I am always an advocate of early return to avoid indentation, just didn't see it here. THX!

Copy link
Member Author

Choose a reason for hiding this comment

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

Opps, not so fast. This skips the following important code when you do the break that you added, so still need the if/else of have repeated code.

					provider = secureProvider
					lc.Info("Created SecretClient")

Copy link
Member Author

Choose a reason for hiding this comment

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

OK, was able to move that code up before handling of the secrets file and now the break you added will work.

Copy link
Member Author

Choose a reason for hiding this comment

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

Updated

err = secureProvider.LoadServiceSecrets(secretStoreConfig)
if err != nil {
return nil, err
}
} else {
lc.Infof("SecretsFile not set, skipping seeding of service secrets.")
}

provider = secureProvider
lc.Info("Created SecretClient")
break
Expand Down Expand Up @@ -107,6 +119,7 @@ func getSecretConfig(secretStoreInfo config.SecretStoreInfo, tokenLoader authtok
Host: secretStoreInfo.Host,
Port: secretStoreInfo.Port,
Path: addEdgeXSecretPathPrefix(secretStoreInfo.Path),
SecretsFile: secretStoreInfo.SecretsFile,
Protocol: secretStoreInfo.Protocol,
Namespace: secretStoreInfo.Namespace,
RootCaCertPath: secretStoreInfo.RootCaCertPath,
Expand Down
84 changes: 83 additions & 1 deletion bootstrap/secret/secure.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ package secret
import (
"errors"
"fmt"
"os"
"strings"
"sync"
"time"

"github.com/edgexfoundry/go-mod-core-contracts/v2/dtos/common"
"github.com/hashicorp/go-multierror"

"github.com/edgexfoundry/go-mod-bootstrap/v2/bootstrap/interfaces"
"github.com/edgexfoundry/go-mod-bootstrap/v2/config"

"github.com/edgexfoundry/go-mod-core-contracts/v2/clients/logger"

"github.com/edgexfoundry/go-mod-secrets/v2/pkg/token/authtokenloader"
"github.com/edgexfoundry/go-mod-secrets/v2/secrets"
)
Expand Down Expand Up @@ -169,7 +176,7 @@ func (p *SecureProvider) GetAccessToken(tokenType string, serviceKey string) (st
}
}

// defaultTokenExpiredCallback is the default implementation of tokenExpiredCallback function
// DefaultTokenExpiredCallback is the default implementation of tokenExpiredCallback function
// It utilizes the tokenFile to re-read the token and enable retry if any update from the expired token
func (p *SecureProvider) DefaultTokenExpiredCallback(expiredToken string) (replacementToken string, retry bool) {
tokenFile := p.configuration.GetBootstrap().SecretStore.TokenFile
Expand All @@ -190,3 +197,78 @@ func (p *SecureProvider) DefaultTokenExpiredCallback(expiredToken string) (repla

return reReadToken, true
}

// LoadServiceSecrets loads the service secrets from the specified file and stores them in the service's SecretStore
func (p *SecureProvider) LoadServiceSecrets(secretStoreConfig config.SecretStoreInfo) error {

contents, err := os.ReadFile(secretStoreConfig.SecretsFile)
if err != nil {
return fmt.Errorf("seeding secrets failed: %s", err.Error())
lenny-goodell marked this conversation as resolved.
Show resolved Hide resolved
}

data, seedingErrs := p.seedSecrets(contents)

if secretStoreConfig.DisableScrubSecretsFile {
p.lc.Infof("Scrubbing of secrets file disable.")
return seedingErrs
}

if err := os.WriteFile(secretStoreConfig.SecretsFile, data, 0); err != nil {
return fmt.Errorf("seeding secrets failed: unable to overwrite file with secret data removed: %s", err.Error())
lenny-goodell marked this conversation as resolved.
Show resolved Hide resolved
}

p.lc.Infof("Scrubbing of secrets file complete.")

return seedingErrs
}

func (p *SecureProvider) seedSecrets(contents []byte) ([]byte, error) {
lenny-goodell marked this conversation as resolved.
Show resolved Hide resolved
serviceSecrets, err := UnmarshalServiceSecretsJson(contents)
if err != nil {
return nil, fmt.Errorf("seeding secrets failed unmarshaling JSON: %s", err.Error())
}

p.lc.Infof("Seeding %d Service Secrets", len(serviceSecrets.Secrets))

var seedingErrs error
for index, secret := range serviceSecrets.Secrets {
if secret.Imported {
p.lc.Infof("Secret for '%s' already imported. Skipping...", secret.Path)
continue
}

// At this pint the JSON validation and above check cover all the required validation, so go to store secret.
path, data := prepareSecret(secret)
err := p.StoreSecret(path, data)
if err != nil {
message := fmt.Sprintf("failed to store secret for '%s': %s", secret.Path, err.Error())
p.lc.Errorf(message)
seedingErrs = multierror.Append(seedingErrs, errors.New(message))
continue
}

p.lc.Infof("Secret for '%s' successfully stored.", secret.Path)

serviceSecrets.Secrets[index].Imported = true
serviceSecrets.Secrets[index].SecretData = make([]common.SecretDataKeyValue, 0)
}

// Now need to write the file back over with the imported secrets' secretData removed.
data, err := serviceSecrets.MarshalJson()
if err != nil {
return nil, fmt.Errorf("seeding secrets failed marshaling back to JSON to clear secrets: %s", err.Error())
}

return data, seedingErrs
}

func prepareSecret(secret ServiceSecret) (string, map[string]string) {
var secretsKV = make(map[string]string)
for _, secret := range secret.SecretData {
secretsKV[secret.Key] = secret.Value
}

path := strings.TrimSpace(secret.Path)

return path, secretsKV
}
49 changes: 49 additions & 0 deletions bootstrap/secret/secure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ import (
"testing"
"time"

mock2 "github.com/stretchr/testify/mock"

bootstrapConfig "github.com/edgexfoundry/go-mod-bootstrap/v2/config"

"github.com/edgexfoundry/go-mod-core-contracts/v2/clients/logger"

"github.com/edgexfoundry/go-mod-secrets/v2/pkg"
mocks2 "github.com/edgexfoundry/go-mod-secrets/v2/pkg/token/authtokenloader/mocks"
"github.com/edgexfoundry/go-mod-secrets/v2/secrets"
Expand Down Expand Up @@ -251,3 +254,49 @@ func TestSecureProvider_GetAccessToken(t *testing.T) {
})
}
}

func TestSecureProvider_seedSecrets(t *testing.T) {
allGood := `{"secrets": [{"path": "auth","imported": false,"secretData": [{"key": "user1","value": "password1"}]}]}`
allGoodExpected := `{"secrets":[{"path":"auth","imported":true,"secretData":[]}]}`
badJson := `{"secrets": [{"path": "","imported": false,"secretData": null}]}`

tests := []struct {
name string
secretsJson string
expectedJson string
mockError bool
expectedError string
}{
{"Valid", allGood, allGoodExpected, false, ""},
{"Partial Valid", allGood, allGoodExpected, false, ""},
{"Bad JSON", badJson, "", false, "seeding secrets failed unmarshaling JSON: ServiceSecrets.Secrets[0].Path field should not be empty string; ServiceSecrets.Secrets[0].SecretData field is required"},
{"Store Error", allGood, "", true, "1 error occurred:\n\t* failed to store secret for 'auth': store failed\n\n"},
}

target := NewSecureProvider(TestConfig{}, logger.MockLogger{}, nil)

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {

mock := &mocks.SecretClient{}

if test.mockError {
mock.On("StoreSecrets", mock2.Anything, mock2.Anything).Return(errors.New("store failed")).Once()
} else {
mock.On("StoreSecrets", mock2.Anything, mock2.Anything).Return(nil).Once()
}

target.SetClient(mock)

actual, err := target.seedSecrets([]byte(test.secretsJson))
if len(test.expectedError) > 0 {
require.Error(t, err)
assert.EqualError(t, err, test.expectedError)
return
}

require.NoError(t, err)
assert.Equal(t, test.expectedJson, string(actual))
})
}
}
69 changes: 69 additions & 0 deletions bootstrap/secret/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*******************************************************************************
* Copyright 2021 Intel Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*******************************************************************************/

package secret

import (
"encoding/json"
"fmt"

validation "github.com/edgexfoundry/go-mod-core-contracts/v2/common"
"github.com/edgexfoundry/go-mod-core-contracts/v2/dtos/common"
"github.com/hashicorp/go-multierror"
)

// ServiceSecrets contains the list of secrets to import into a service's SecretStore
type ServiceSecrets struct {
Secrets []ServiceSecret `json:"secrets" validate:"required,gt=0,dive"`
}

// ServiceSecret contains the information about a service's secret to import into a service's SecretStore
type ServiceSecret struct {
Path string `json:"path" validate:"edgex-dto-none-empty-string"`
Imported bool `json:"imported"`
SecretData []common.SecretDataKeyValue `json:"secretData" validate:"required,dive"`
}

// MarshalJson marshal the service's secrets to JSON.
func (s *ServiceSecrets) MarshalJson() ([]byte, error) {
return json.Marshal(s)
}

// UnmarshalServiceSecretsJson un-marshals the JSON containing the services list of secrets
func UnmarshalServiceSecretsJson(data []byte) (*ServiceSecrets, error) {
secrets := &ServiceSecrets{}

if err := json.Unmarshal(data, secrets); err != nil {
return nil, err
}

if err := validation.Validate(secrets); err != nil {
return nil, err
}

var validationErrs error

// Since secretData len validation can't be specified to only validate when Imported=false, we have to do it manually here
for _, secret := range secrets.Secrets {
if !secret.Imported && len(secret.SecretData) == 0 {
validationErrs = multierror.Append(validationErrs, fmt.Errorf("SecretData for '%s' must not be empty when Imported=false", secret.Path))
}
}

if validationErrs != nil {
return nil, validationErrs
}

return secrets, nil
}
Loading