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

Publishing improvements: directory walking; prevent Vault unneeded version increment #602

Merged
merged 13 commits into from Jan 14, 2020
9 changes: 9 additions & 0 deletions README.rst
Expand Up @@ -968,6 +968,7 @@ This command requires a ``.sops.yaml`` configuration file. Below is an example:
vault_kv_mount_name: "secret/" # default
vault_kv_version: 2 # default
path_regex: vault/*
omit_extensions: true
Copy link
Contributor

Choose a reason for hiding this comment

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

Out of curiosity, what's your use case for this?

Copy link
Contributor Author

@mmorev mmorev Jan 10, 2020

Choose a reason for hiding this comment

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

Thanks for this question!
The goal is to use Vault as secrets source for several applications in different environments. As soon as it is a team work, we want to view diffs and approve/reject changes just like code changes in any Git platform (github, bitbucket) we do.
Vault has no option to stage changes like this (the only option in Enterprise edition is to approve the fact of write access, without seeing what data will be modified) and no diffs between versions or something. So we decided to store some info in Git/Sops and publish it to Vault in a batch triggered by repository change, and then lock down Vault to read-only mode by policies.
So in Vault we have some secrets schema, extensions are not needed there.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for explaining, it's always good to see how people use sops :)


The above configuration will place all files under ``s3/*`` into the S3 bucket ``sops-secrets``,
all files under ``gcs/*`` into the GCS bucket ``sops-secrets``, and the contents of all files under
Expand All @@ -977,6 +978,11 @@ published to S3 and GCS, it will decrypt them and re-encrypt them using the

You would deploy a file to S3 with a command like: ``sops publish s3/app.yaml``

To publish all files in selected directory recursively, you need to specify ``--recurse`` flag.
mmorev marked this conversation as resolved.
Show resolved Hide resolved

If you don't want file extension to appear in destination secret path, use ``--omit-extensions``
flag or same ``.sops.yaml`` option.
mmorev marked this conversation as resolved.
Show resolved Hide resolved

Publishing to Vault
*******************

Expand All @@ -991,6 +997,9 @@ configuring the client.
``vault_kv_mount_name`` is used if your Vault KV is mounted somewhere other than ``secret/``.
``vault_kv_version`` supports ``1`` and ``2``, with ``2`` being the default.

If destination secret path already exists in Vault and contains same data as source file, it will
mmorev marked this conversation as resolved.
Show resolved Hide resolved
be skipped.

Below is an example of publishing to Vault (using token auth with a local dev instance of Vault).

.. code:: bash
Expand Down
24 changes: 16 additions & 8 deletions cmd/sops/main.go
Expand Up @@ -211,13 +211,21 @@ func main() {
},
{
Name: "publish",
Usage: "Publish sops file to a configured destination",
Usage: "Publish sops file or directory to a configured destination",
ArgsUsage: `file`,
Flags: append([]cli.Flag{
cli.BoolFlag{
Name: "yes, y",
Usage: `pre-approve all changes and run non-interactively`,
},
cli.BoolFlag{
Name: "omit-extensions",
Usage: "Omit file extensions in destination path when publishing sops file to configured destinations",
},
cli.BoolFlag{
Name: "recurse",
Copy link
Contributor

Choose a reason for hiding this comment

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

Please change all mentions of this to recursive

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed in 3ab2d41

Usage: "If source path is directory, publish all its content recursively",
mmorev marked this conversation as resolved.
Show resolved Hide resolved
},
cli.BoolFlag{
Name: "verbose",
Usage: "Enable verbose logging output",
Expand All @@ -235,14 +243,14 @@ func main() {
return common.NewExitError("Error: no file specified", codes.NoFileSpecified)
}
fileName := c.Args()[0]
inputStore := inputStore(c, fileName)
err = publishcmd.Run(publishcmd.Opts{
ConfigPath: configPath,
InputPath: fileName,
InputStore: inputStore,
Cipher: aes.NewCipher(),
KeyServices: keyservices(c),
Interactive: !c.Bool("yes"),
ConfigPath: configPath,
InputPath: fileName,
Cipher: aes.NewCipher(),
KeyServices: keyservices(c),
Interactive: !c.Bool("yes"),
OmitExtensions: c.Bool("omit-extensions"),
Recurse: c.Bool("recurse"),
})
if cliErr, ok := err.(*cli.ExitError); ok && cliErr != nil {
return cliErr
Expand Down
53 changes: 41 additions & 12 deletions cmd/sops/subcommand/publish/publish.go
Expand Up @@ -6,6 +6,7 @@ import (
"io/ioutil"
"os"
"path/filepath"
"strings"

"go.mozilla.org/sops/v3"
"go.mozilla.org/sops/v3/cmd/sops/codes"
Expand All @@ -27,12 +28,14 @@ func init() {

// Opts represents publish options and config
type Opts struct {
Interactive bool
Cipher sops.Cipher
ConfigPath string
InputPath string
KeyServices []keyservice.KeyServiceClient
InputStore sops.Store
Interactive bool
Cipher sops.Cipher
ConfigPath string
InputPath string
KeyServices []keyservice.KeyServiceClient
InputStore sops.Store
OmitExtensions bool
Recurse bool
}

// Run publish operation
Expand All @@ -46,10 +49,27 @@ func Run(opts Opts) error {
if err != nil {
return err
}
if info.IsDir() {
if info.IsDir() && !opts.Recurse {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd nest this,

if info.IsDir() {
  if !opts.Recursive {
    return fmt.Errorf("can't operate on a directory")
  } 
   err = filepath.Walk(opts.InputPat...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Made some rework in 3ab2d41. See below.

return fmt.Errorf("can't operate on a directory")
} else if info.IsDir() && opts.Recurse {
err = filepath.Walk(opts.InputPath, func(subPath string, info os.FileInfo, err error) error {
Copy link
Contributor

Choose a reason for hiding this comment

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

You should handle the error passed into the function

Copy link
Contributor Author

Choose a reason for hiding this comment

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

fixed in 3ab2d41

subAbsPath, _ := filepath.Abs(subPath)
if !info.IsDir() && subAbsPath != path {
Copy link
Contributor

Choose a reason for hiding this comment

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

Could subAbsPath != path ever be false? path is the original input path, and by the mere fact that we got to this code path, we've already established it's a directory, so !info.,IsDir() would be false anyway and it would short-circuit. Am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for this comment. I just found out I had abused filepath.Walk function by using additional recursion inside it. So i moved out Walk call, IsDir check and store type detection to main.go: https://github.com/mozilla/sops/blob/3ab2d41c2f596e1fe5fa59c72a559514bcc91080/cmd/sops/main.go#L253

subOpts := opts
subOpts.InputPath = subPath
return Run(subOpts)
} else {
return nil
}
})
if err != nil {
return err
}
return nil
}
_, fileName := filepath.Split(path)
fileSuffix := filepath.Ext(path)
opts.InputStore = common.DefaultStoreForPathOrFormat(path, fileSuffix)
mmorev marked this conversation as resolved.
Show resolved Hide resolved
destinationPath := opts.InputPath

conf, err := config.LoadDestinationRuleForFile(opts.ConfigPath, opts.InputPath, make(map[string]*string))
if err != nil {
Expand All @@ -58,6 +78,9 @@ func Run(opts Opts) error {
if conf.Destination == nil {
return errors.New("no destination configured for this file")
}
if opts.OmitExtensions || conf.OmitExtensions {
destinationPath = strings.TrimSuffix(destinationPath, fileSuffix)
}

// Check that this is a sops-encrypted file
tree, err := common.LoadEncryptedFile(opts.InputStore, opts.InputPath)
Expand Down Expand Up @@ -146,22 +169,28 @@ func Run(opts Opts) error {
if opts.Interactive {
var response string
for response != "y" && response != "n" {
fmt.Printf("uploading %s to %s ? (y/n): ", path, conf.Destination.Path(fileName))
fmt.Printf("uploading %s to %s ? (y/n): ", path, conf.Destination.Path(destinationPath))
_, err := fmt.Scanln(&response)
if err != nil {
return err
}
}
if response == "n" {
return errors.New("Publish canceled")
msg := fmt.Sprintf("Publication of %s canceled", path)
if opts.Recurse {
fmt.Println(msg)
return nil
} else {
return errors.New(msg)
}
}
}

switch dest := conf.Destination.(type) {
case *publish.S3Destination, *publish.GCSDestination:
err = dest.Upload(fileContents, fileName)
err = dest.Upload(fileContents, destinationPath)
case *publish.VaultDestination:
err = dest.UploadUnencrypted(data, fileName)
err = dest.UploadUnencrypted(data, destinationPath)
}

if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions config/config.go
Expand Up @@ -100,6 +100,7 @@ type destinationRule struct {
VaultKVMountName string `yaml:"vault_kv_mount_name"`
VaultKVVersion int `yaml:"vault_kv_version"`
RecreationRule creationRule `yaml:"recreation_rule,omitempty"`
OmitExtensions bool `yaml:"omit_extensions"`
}

type creationRule struct {
Expand Down Expand Up @@ -133,6 +134,7 @@ type Config struct {
EncryptedSuffix string
EncryptedRegex string
Destination publish.Destination
OmitExtensions bool
}

func getKeyGroupsFromCreationRule(cRule *creationRule, kmsEncryptionContext map[string]*string) ([]sops.KeyGroup, error) {
Expand Down Expand Up @@ -266,6 +268,7 @@ func parseDestinationRuleForFile(conf *configFile, filePath string, kmsEncryptio
return nil, err
}
config.Destination = dest
config.OmitExtensions = dRule.OmitExtensions

return config, nil
}
Expand Down
12 changes: 12 additions & 0 deletions publish/vault.go
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"

"github.com/google/go-cmp/cmp"
vault "github.com/hashicorp/vault/api"
)

Expand Down Expand Up @@ -65,6 +66,17 @@ func (vaultd *VaultDestination) UploadUnencrypted(data map[string]interface{}, f
}
}

existingSecret, err := client.Logical().Read(vaultd.secretsPath(fileName))
if err != nil {
return err
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it might be good to keep going regardless (after printing a warning) if there's an error reading the secret. I'm imagining a situation where someone has given SOPS permission to only write to Vault.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea. Fixed this behavior in 01b5fb6: log warn when no read access, log info when data not changed

}
if existingSecret != nil {
if cmp.Equal(data, existingSecret.Data["data"]) {
fmt.Printf("Secret in %s is already up-to-date.\n", vaultd.secretsPath(fileName))
return nil
}
}

secretsData := make(map[string]interface{})

if vaultd.kvVersion == 1 {
Expand Down