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.
var v config.Value
if secret {
c, cerr := state.SymmetricCrypter()
c, cerr := backend.GetStackCrypter(s)
if cerr != nil {
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.
var decrypter config.Decrypter
if cfg.HasSecureValue() && showSecrets {
decrypter, err = state.SymmetricCrypter()
decrypter, err = backend.GetStackCrypter(stack)
if err != nil {
return err
}
@ -326,7 +326,7 @@ func getConfig(stack backend.Stack, key tokens.ModuleMember) error {
var d config.Decrypter
if v.Secure() {
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")
}
} else {

View file

@ -6,6 +6,7 @@ package backend
import (
"github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/resource/config"
"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() ([]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(stackName tokens.QName, debug bool, opts engine.PreviewOptions) error
// 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.
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"
// 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.
type UpdateProgramRequest struct {
// Properties from the Project file. Subset of pack.Package.
@ -11,7 +19,7 @@ type UpdateProgramRequest struct {
Description string `json:"description"`
// 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.

View file

@ -3,6 +3,7 @@
package cloud
import (
"encoding/base64"
"fmt"
"io"
"io/ioutil"
@ -25,6 +26,7 @@ import (
"github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/pack"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/archive"
"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)
}
// 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.
type updateKind string
@ -319,29 +370,6 @@ func (b *cloudBackend) listCloudStacks() ([]apitype.Stack, error) {
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
// working directory.
func getCloudProjectIdentifier() (*cloudProjectIdentifier, error) {
@ -391,11 +419,20 @@ func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName) (apitype
return ""
}
// Gather up configuration.
// TODO(pulumi-service/issues/221): Have pulumi.com handle the encryption/decryption.
textConfig, err := b.getDecryptedConfig(stackName)
// Convert the configuration into its wire form.
cfg, err := state.Configuration(b.d, stackName)
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{
@ -403,7 +440,7 @@ func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName) (apitype
Runtime: pkg.Runtime,
Main: pkg.Main,
Description: valueOrEmpty(pkg.Description),
Config: textConfig,
Config: wireConfig,
}, nil
}

View file

@ -16,6 +16,7 @@ import (
"github.com/pulumi/pulumi/pkg/encoding"
"github.com/pulumi/pulumi/pkg/engine"
"github.com/pulumi/pulumi/pkg/operations"
"github.com/pulumi/pulumi/pkg/resource/config"
"github.com/pulumi/pulumi/pkg/tokens"
"github.com/pulumi/pulumi/pkg/util/contract"
"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)
}
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 {
pulumiEngine, err := b.getEngine(stackName)
if err != nil {
@ -208,7 +213,7 @@ func (b *localBackend) getEngine(stackName tokens.QName) (engine.Engine, error)
return engine.Engine{}, err
}
decrypter, err := state.DefaultCrypter(cfg)
decrypter, err := defaultCrypter(cfg)
if err != nil {
return engine.Engine{}, err
}

View file

@ -1,6 +1,6 @@
// Copyright 2016-2017, Pulumi Corporation. All rights reserved.
package state
package local
import (
cryptorand "crypto/rand"
@ -24,19 +24,19 @@ func readPassphrase(prompt string) (string, error) {
return cmdutil.ReadConsoleNoEcho(prompt)
}
// DefaultCrypter gets the right value encrypter/decrypter given the project configuration.
func DefaultCrypter(cfg config.Map) (config.Crypter, error) {
// defaultCrypter gets the right value encrypter/decrypter given the project configuration.
func defaultCrypter(cfg config.Map) (config.Crypter, error) {
// If there is no config, we can use a standard panic crypter.
if !cfg.HasSecureValue() {
return config.NewPanicCrypter(), nil
}
// Otherwise, we will use an encrypted one.
return SymmetricCrypter()
return symmetricCrypter()
}
// 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.
pkg, err := workspace.GetPackage()
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)
}
// 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.
func GetStackLogs(s Stack, query operations.LogQuery) ([]operations.LogEntry, error) {
return s.Backend().GetLogs(s.Name(), query)

View file

@ -21,9 +21,9 @@ type Encrypter interface {
EncryptValue(plaintext string) (string, error)
}
// Decrypter decrypts encrypted cyphertext to its plaintext representation.
// Decrypter decrypts encrypted ciphertext to its plaintext representation.
type Decrypter interface {
DecryptValue(cypertext string) (string, error)
DecryptValue(ciphertext string) (string, error)
}
// Crypter can both encrypt and decrypt values.
@ -32,6 +32,15 @@ type Crypter interface {
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
// 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.