Support secrets for cloud stacks.

Use the new {en,de}crypt endpoints in the Pulumi.com API to secure
secret config values. The ciphertext for a secret config value is bound
to the stack to which it applies and cannot be shared with other stacks
(e.g. by copy/pasting it around in Pulumi.yaml). All secrets will need
to be encrypted once per target stack.
This commit is contained in:
Pat Gavlin 2017-12-22 07:38:21 -08:00
parent af087103b9
commit e4d9eb6fd3
9 changed files with 132 additions and 40 deletions

View file

@ -173,7 +173,7 @@ func newConfigSetCmd(stack *string) *cobra.Command {
// Encrypt the config value if needed. // Encrypt the config value if needed.
var v config.Value var v config.Value
if secret { if secret {
c, cerr := state.SymmetricCrypter() c, cerr := backend.GetStackCrypter(s)
if cerr != nil { if cerr != nil {
return cerr return cerr
} }
@ -277,7 +277,7 @@ func listConfig(stack backend.Stack, showSecrets bool) error {
// By default, we will use a blinding decrypter to show '******'. If requested, display secrets in plaintext. // By default, we will use a blinding decrypter to show '******'. If requested, display secrets in plaintext.
var decrypter config.Decrypter var decrypter config.Decrypter
if cfg.HasSecureValue() && showSecrets { if cfg.HasSecureValue() && showSecrets {
decrypter, err = state.SymmetricCrypter() decrypter, err = backend.GetStackCrypter(stack)
if err != nil { if err != nil {
return err return err
} }
@ -326,7 +326,7 @@ func getConfig(stack backend.Stack, key tokens.ModuleMember) error {
var d config.Decrypter var d config.Decrypter
if v.Secure() { if v.Secure() {
var err error var err error
if d, err = state.DefaultCrypter(cfg); err != nil { if d, err = backend.GetStackCrypter(stack); err != nil {
return errors.Wrap(err, "could not create a decrypter") return errors.Wrap(err, "could not create a decrypter")
} }
} else { } else {

View file

@ -6,6 +6,7 @@ package backend
import ( import (
"github.com/pulumi/pulumi/pkg/engine" "github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations" "github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/tokens"
) )
@ -26,6 +27,9 @@ type Backend interface {
// ListStacks returns a list of stack summaries for all known stacks in the target backend. // ListStacks returns a list of stack summaries for all known stacks in the target backend.
ListStacks() ([]Stack, error) ListStacks() ([]Stack, error)
// GetStackCrypter returns an encrypter/decrypter for the given stack's secret config values.
GetStackCrypter(stack tokens.QName) (config.Crypter, error)
// Preview initiates a preview of the current workspace's contents. // Preview initiates a preview of the current workspace's contents.
Preview(stackName tokens.QName, debug bool, opts engine.PreviewOptions) error Preview(stackName tokens.QName, debug bool, opts engine.PreviewOptions) error
// Update updates the target stack with the current workspace's contents (config and code). // Update updates the target stack with the current workspace's contents (config and code).

View file

@ -43,3 +43,27 @@ type CreateStackResponse struct {
// The name of the cloud used if the default was sent. // The name of the cloud used if the default was sent.
CloudName string `json:"cloudName"` CloudName string `json:"cloudName"`
} }
// EncryptValueRequest defines the request body for encrypting a value.
type EncryptValueRequest struct {
// The value to encrypt.
Plaintext []byte `json:"plaintext"`
}
// EncryptValueResponse defines the response body for an encrypted value.
type EncryptValueResponse struct {
// The encrypted value.
Ciphertext []byte `json:"ciphertext"`
}
// DecryptValueRequest defines the request body for decrypting a value.
type DecryptValueRequest struct {
// The value to decrypt.
Ciphertext []byte `json:"ciphertext"`
}
// DecryptValueResponse defines the response body for a decrypted value.
type DecryptValueResponse struct {
// The decrypted value.
Plaintext []byte `json:"plaintext"`
}

View file

@ -2,6 +2,14 @@ package apitype
import "github.com/pulumi/pulumi/pkg/tokens" import "github.com/pulumi/pulumi/pkg/tokens"
// ConfigValue describes a single (possibly secret) configuration value.
type ConfigValue struct {
// String is either the plaintext value (for non-secrets) or the base64-encoded ciphertext (for secrets).
String string `json:"string"`
// Secret is true if this value is a secret and false otherwise.
Secret bool `json:"secret"`
}
// UpdateProgramRequest is the request type for updating (aka deploying) a Pulumi program. // UpdateProgramRequest is the request type for updating (aka deploying) a Pulumi program.
type UpdateProgramRequest struct { type UpdateProgramRequest struct {
// Properties from the Project file. Subset of pack.Package. // Properties from the Project file. Subset of pack.Package.
@ -11,7 +19,7 @@ type UpdateProgramRequest struct {
Description string `json:"description"` Description string `json:"description"`
// Configuration values. // Configuration values.
Config map[tokens.ModuleMember]string `json:"config"` Config map[tokens.ModuleMember]ConfigValue `json:"config"`
} }
// UpdateProgramResponse is the result of an update program request. // UpdateProgramResponse is the result of an update program request.

View file

@ -3,6 +3,7 @@
package cloud package cloud
import ( import (
"encoding/base64"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@ -25,6 +26,7 @@ import (
"github.com/pulumi/pulumi/pkg/engine" "github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations" "github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/pack" "github.com/pulumi/pulumi/pkg/pack"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/archive" "github.com/pulumi/pulumi/pkg/util/archive"
"github.com/pulumi/pulumi/pkg/util/cmdutil" "github.com/pulumi/pulumi/pkg/util/cmdutil"
@ -135,6 +137,55 @@ func (b *cloudBackend) RemoveStack(stackName tokens.QName, force bool) (bool, er
return false, pulumiRESTCall(b.cloudURL, "DELETE", path, nil, nil, nil) return false, pulumiRESTCall(b.cloudURL, "DELETE", path, nil, nil, nil)
} }
// cloudCrypter is an encrypter/decrypter that uses the Pulumi cloud to encrypt/decrypt a stack's secrets.
type cloudCrypter struct {
backend *cloudBackend
stackName string
}
func (c *cloudCrypter) EncryptValue(plaintext string) (string, error) {
projID, err := getCloudProjectIdentifier()
if err != nil {
return "", err
}
path := fmt.Sprintf("/orgs/%s/programs/%s/%s/stacks/%s/encrypt",
projID.Owner, projID.Repository, projID.Project, c.stackName)
var resp apitype.EncryptValueResponse
req := apitype.EncryptValueRequest{Plaintext: []byte(plaintext)}
if err := pulumiRESTCall(c.backend.cloudURL, "POST", path, &req, &resp, nil); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(resp.Ciphertext), nil
}
func (c *cloudCrypter) DecryptValue(cipherstring string) (string, error) {
projID, err := getCloudProjectIdentifier()
if err != nil {
return "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(cipherstring)
if err != nil {
return "", err
}
path := fmt.Sprintf("/orgs/%s/programs/%s/%s/stacks/%s/decrypt",
projID.Owner, projID.Repository, projID.Project, c.stackName)
var resp apitype.DecryptValueResponse
req := apitype.DecryptValueRequest{Ciphertext: ciphertext}
if err := pulumiRESTCall(c.backend.cloudURL, "POST", path, &req, &resp, nil); err != nil {
return "", err
}
return string(resp.Plaintext), nil
}
func (b *cloudBackend) GetStackCrypter(stackName tokens.QName) (config.Crypter, error) {
return &cloudCrypter{backend: b, stackName: string(stackName)}, nil
}
// updateKind is an enum for describing the kinds of updates we support. // updateKind is an enum for describing the kinds of updates we support.
type updateKind string type updateKind string
@ -319,29 +370,6 @@ func (b *cloudBackend) listCloudStacks() ([]apitype.Stack, error) {
return stacks, nil return stacks, nil
} }
// getDecryptedConfig returns the stack's configuration with any secrets in plain-text.
func (b *cloudBackend) getDecryptedConfig(stackName tokens.QName) (map[tokens.ModuleMember]string, error) {
cfg, err := state.Configuration(b.d, stackName)
if err != nil {
return nil, errors.Wrap(err, "getting configuration")
}
decrypter, err := state.DefaultCrypter(cfg)
if err != nil {
return nil, errors.Wrap(err, "getting a default encrypter")
}
textConfig := make(map[tokens.ModuleMember]string)
for key := range cfg {
decrypted, err := cfg[key].Value(decrypter)
if err != nil {
return nil, errors.Wrap(err, "could not decrypt configuration value")
}
textConfig[key] = decrypted
}
return textConfig, nil
}
// getCloudProjectIdentifier returns information about the current repository and project, based on the current // getCloudProjectIdentifier returns information about the current repository and project, based on the current
// working directory. // working directory.
func getCloudProjectIdentifier() (*cloudProjectIdentifier, error) { func getCloudProjectIdentifier() (*cloudProjectIdentifier, error) {
@ -391,11 +419,20 @@ func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName) (apitype
return "" return ""
} }
// Gather up configuration. // Convert the configuration into its wire form.
// TODO(pulumi-service/issues/221): Have pulumi.com handle the encryption/decryption. cfg, err := state.Configuration(b.d, stackName)
textConfig, err := b.getDecryptedConfig(stackName)
if err != nil { if err != nil {
return apitype.UpdateProgramRequest{}, errors.Wrap(err, "getting decrypted configuration") return apitype.UpdateProgramRequest{}, errors.Wrap(err, "getting configuration")
}
wireConfig := make(map[tokens.ModuleMember]apitype.ConfigValue)
for k, cv := range cfg {
v, err := cv.Value(config.NopDecrypter)
contract.Assert(err == nil)
wireConfig[k] = apitype.ConfigValue{
String: v,
Secret: cv.Secure(),
}
} }
return apitype.UpdateProgramRequest{ return apitype.UpdateProgramRequest{
@ -403,7 +440,7 @@ func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName) (apitype
Runtime: pkg.Runtime, Runtime: pkg.Runtime,
Main: pkg.Main, Main: pkg.Main,
Description: valueOrEmpty(pkg.Description), Description: valueOrEmpty(pkg.Description),
Config: textConfig, Config: wireConfig,
}, nil }, nil
} }

View file

@ -16,6 +16,7 @@ import (
"github.com/pulumi/pulumi/pkg/encoding" "github.com/pulumi/pulumi/pkg/encoding"
"github.com/pulumi/pulumi/pkg/engine" "github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations" "github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens" "github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/contract" "github.com/pulumi/pulumi/pkg/util/contract"
"github.com/pulumi/pulumi/pkg/workspace" "github.com/pulumi/pulumi/pkg/workspace"
@ -97,6 +98,10 @@ func (b *localBackend) RemoveStack(stackName tokens.QName, force bool) (bool, er
return false, removeStack(stackName) return false, removeStack(stackName)
} }
func (b *localBackend) GetStackCrypter(stackName tokens.QName) (config.Crypter, error) {
return symmetricCrypter()
}
func (b *localBackend) Preview(stackName tokens.QName, debug bool, opts engine.PreviewOptions) error { func (b *localBackend) Preview(stackName tokens.QName, debug bool, opts engine.PreviewOptions) error {
pulumiEngine, err := b.getEngine(stackName) pulumiEngine, err := b.getEngine(stackName)
if err != nil { if err != nil {
@ -208,7 +213,7 @@ func (b *localBackend) getEngine(stackName tokens.QName) (engine.Engine, error)
return engine.Engine{}, err return engine.Engine{}, err
} }
decrypter, err := state.DefaultCrypter(cfg) decrypter, err := defaultCrypter(cfg)
if err != nil { if err != nil {
return engine.Engine{}, err return engine.Engine{}, err
} }

View file

@ -1,6 +1,6 @@
// Copyright 2016-2017, Pulumi Corporation. All rights reserved. // Copyright 2016-2017, Pulumi Corporation. All rights reserved.
package state package local
import ( import (
cryptorand "crypto/rand" cryptorand "crypto/rand"
@ -24,19 +24,19 @@ func readPassphrase(prompt string) (string, error) {
return cmdutil.ReadConsoleNoEcho(prompt) return cmdutil.ReadConsoleNoEcho(prompt)
} }
// DefaultCrypter gets the right value encrypter/decrypter given the project configuration. // defaultCrypter gets the right value encrypter/decrypter given the project configuration.
func DefaultCrypter(cfg config.Map) (config.Crypter, error) { func defaultCrypter(cfg config.Map) (config.Crypter, error) {
// If there is no config, we can use a standard panic crypter. // If there is no config, we can use a standard panic crypter.
if !cfg.HasSecureValue() { if !cfg.HasSecureValue() {
return config.NewPanicCrypter(), nil return config.NewPanicCrypter(), nil
} }
// Otherwise, we will use an encrypted one. // Otherwise, we will use an encrypted one.
return SymmetricCrypter() return symmetricCrypter()
} }
// SymmetricCrypter gets the right value encrypter/decrypter for this project. // SymmetricCrypter gets the right value encrypter/decrypter for this project.
func SymmetricCrypter() (config.Crypter, error) { func symmetricCrypter() (config.Crypter, error) {
// First, read the package to see if we've got a key. // First, read the package to see if we've got a key.
pkg, err := workspace.GetPackage() pkg, err := workspace.GetPackage()
if err != nil { if err != nil {

View file

@ -44,6 +44,11 @@ func DestroyStack(s Stack, debug bool, opts engine.DestroyOptions) error {
return s.Backend().Destroy(s.Name(), debug, opts) return s.Backend().Destroy(s.Name(), debug, opts)
} }
// GetStackCrypter fetches the encrypter/decrypter for a stack.
func GetStackCrypter(s Stack) (config.Crypter, error) {
return s.Backend().GetStackCrypter(s.Name())
}
// GetStackLogs fetches a list of log entries for the current stack in the current backend. // GetStackLogs fetches a list of log entries for the current stack in the current backend.
func GetStackLogs(s Stack, query operations.LogQuery) ([]operations.LogEntry, error) { func GetStackLogs(s Stack, query operations.LogQuery) ([]operations.LogEntry, error) {
return s.Backend().GetLogs(s.Name(), query) return s.Backend().GetLogs(s.Name(), query)

View file

@ -21,9 +21,9 @@ type Encrypter interface {
EncryptValue(plaintext string) (string, error) EncryptValue(plaintext string) (string, error)
} }
// Decrypter decrypts encrypted cyphertext to its plaintext representation. // Decrypter decrypts encrypted ciphertext to its plaintext representation.
type Decrypter interface { type Decrypter interface {
DecryptValue(cypertext string) (string, error) DecryptValue(ciphertext string) (string, error)
} }
// Crypter can both encrypt and decrypt values. // Crypter can both encrypt and decrypt values.
@ -32,6 +32,15 @@ type Crypter interface {
Decrypter Decrypter
} }
// A nopDecrypter simply returns the ciphertext as-is.
type nopDecrypter int
var NopDecrypter Decrypter = nopDecrypter(0)
func (nopDecrypter) DecryptValue(ciphertext string) (string, error) {
return ciphertext, nil
}
// NewBlindingDecrypter returns a Decrypter that instead of decrypting data, just returns "********", it can // NewBlindingDecrypter returns a Decrypter that instead of decrypting data, just returns "********", it can
// be used when you want to display configuration information to a user but don't want to prompt for a password // be used when you want to display configuration information to a user but don't want to prompt for a password
// so secrets will not be decrypted. // so secrets will not be decrypted.