Skip to content

Commit

Permalink
Secret experiment
Browse files Browse the repository at this point in the history
  • Loading branch information
moskyb committed Nov 24, 2022
1 parent babe4c1 commit 9028219
Show file tree
Hide file tree
Showing 8 changed files with 429 additions and 0 deletions.
76 changes: 76 additions & 0 deletions bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/buildkite/agent/v3/hook"
"github.com/buildkite/agent/v3/process"
"github.com/buildkite/agent/v3/redaction"
"github.com/buildkite/agent/v3/secrets"
"github.com/buildkite/agent/v3/tracetools"
"github.com/buildkite/agent/v3/utils"
"github.com/buildkite/roko"
Expand Down Expand Up @@ -113,6 +114,81 @@ func (b *Bootstrap) Run(ctx context.Context) (exitCode int) {
return shell.GetExitCode(err)
}

// Just pretend that these two jsons live in the pipeline.yml, and get passed through as env vars by the backend
secretProviderRegistryJSON := `[
{
"id": "ssm",
"type": "aws-ssm",
"config": {}
},
{
"id": "other-ssm",
"type": "aws-ssm",
"config": {
"role_arn": "arn:aws:iam::555555555555:role/benno-test-role-delete-after-2022-11-29"
}
}
]`

secretProviderRegistry, err := secrets.NewProviderRegistryFromJSON(secrets.ProviderRegistryConfig{Shell: b.shell}, secretProviderRegistryJSON)
if err != nil {
b.shell.Errorf("Error creating secret provider registry: %v", err)
return 1
}

secretsJSON := []byte(`[
{
"env_var": "SUPER_SECRET_ENV_VAR",
"key": "/benno/secret/envar",
"provider_id": "ssm"
},
{
"file": "/Users/ben/secret-file",
"key": "/benno/secret/file",
"provider_id": "other-ssm"
}
]`)

var secretConfigs []secrets.SecretConfig
err = json.Unmarshal(secretsJSON, &secretConfigs)
if err != nil {
b.shell.Errorf("Error unmarshalling secrets: %v", err)
return 1
}

validationErrs := make([]error, 0, len(secretConfigs))
for _, c := range secretConfigs {
err := c.Validate()
validationErrs = append(validationErrs, err)
}

for _, err := range validationErrs {
b.shell.Errorf("Error validating secret config: %v", err)
}

if len(validationErrs) > 0 {
return 1
}

secrets, errors := secretProviderRegistry.FetchAll(secretConfigs)
if len(errors) > 0 {
b.shell.Errorf("Errors fetching secrets:")
for _, err := range errors {
b.shell.Errorf(" %v", err)
}
return 1
}

for _, secret := range secrets {
// TODO: Automatically add env secrets to the redactor
err := secret.Store()
if err != nil {
b.shell.Errorf("Error storing secret: %v", err)
}

defer secret.Cleanup()
}

var includePhase = func(phase string) bool {
if len(b.Phases) == 0 {
return true
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ require (
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync v1.5.2
github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/sasha-s/go-deadlock v0.0.0-20180226215254-237a9547c8a5 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/puzpuzpuz/xsync v1.5.2 h1:yRAP4wqSOZG+/4pxJ08fPTwrfL0IzE/LKQ/cw509qGY=
github.com/puzpuzpuz/xsync v1.5.2/go.mod h1:K98BYhX3k1dQ2M63t1YNVDanbwUPmBCAhNmVrrxfiGg=
github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2 h1:C8RRfIlExwwrXw28G8LkrpWiHUVT4uLowfvjUYJ2Iec=
github.com/qri-io/jsonpointer v0.0.0-20180309164927-168dd9e45cf2/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
github.com/qri-io/jsonschema v0.0.0-20180607150648-d0d3b10ec792 h1:vwTGeGWCew89DI4ZwKCaobGAN7ExvZiBzgn4LZHMVOc=
Expand Down
37 changes: 37 additions & 0 deletions secrets/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package secrets

import (
"encoding/json"
"fmt"
)

type providerCandidate struct {
Type string `json:"type"`
ID string `json:"id"`
Config json.RawMessage `json:"config"`
}

func (r providerCandidate) Initialize() (Provider, error) {
switch r.Type {
case "aws-ssm":
var conf AWSSSMProviderConfig
err := json.Unmarshal(r.Config, &conf)
if err != nil {
return nil, fmt.Errorf("unmarshalling config for aws-ssm provider %s: %v", r.ID, err)
}

ssm, err := NewAWSSSMProvider(r.ID, conf)
if err != nil {
return nil, fmt.Errorf("creating aws-ssm provider %s: %w", r.ID, err)
}

return ssm, nil
default:
return nil, fmt.Errorf("invalid provider type %s for provider %s", r.Type, r.ID)
}
}

// A Provider is a source of secrets, and must provide a way to fetch a secret given some key. Providers must be goroutine-safe.
type Provider interface {
Fetch(key string) (string, error)
}
75 changes: 75 additions & 0 deletions secrets/provider_aws_ssm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package secrets

import (
"fmt"
"os"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ssm"
"github.com/aws/aws-sdk-go/service/sts"
)

type AWSSSMProviderConfig struct {
ID string `json:"id"`
RoleARN string `json:"role_arn"`
AssumeViaOIDC bool `json:"assume_via_oidc"`
}

type AWSSSMProvider struct {
id string
ssmI *ssm.SSM
}

func NewAWSSSMProvider(id string, config AWSSSMProviderConfig) (*AWSSSMProvider, error) {
sess, err := session.NewSession()
if err != nil {
return nil, fmt.Errorf("initialising AWS session: %w", err)
}

return &AWSSSMProvider{
id: id,
ssmI: generateSSMInstance(sess, config),
}, nil
}

func (s *AWSSSMProvider) ID() string {
return s.id
}

func (s *AWSSSMProvider) Type() string {
return "aws-ssm"
}

func (s *AWSSSMProvider) Fetch(key string) (string, error) {
out, err := s.ssmI.GetParameter(&ssm.GetParameterInput{
Name: aws.String(key),
WithDecryption: aws.Bool(true),
})

if err != nil {
return "", fmt.Errorf("retrieving secret %s from SSM Parameter Store: %w", key, err)
}

return *out.Parameter.Value, nil
}

func generateSSMInstance(sess *session.Session, config AWSSSMProviderConfig) *ssm.SSM {
if config.RoleARN == "" {
return ssm.New(sess)
}

if config.AssumeViaOIDC {
stsClient := sts.New(sess)
sessionName := fmt.Sprintf("buildkite-agent-aws-ssm-secrets-provider-%s", os.Getenv("BUILDKITE_JOB_ID"))
// TODO: Use BK OIDC provider instead of some rando file
roleProvider := stscreds.NewWebIdentityRoleProviderWithOptions(stsClient, config.RoleARN, sessionName, stscreds.FetchTokenPath("/build/token"))
creds := credentials.NewCredentials(roleProvider)
return ssm.New(sess, &aws.Config{Credentials: creds})
}

creds := stscreds.NewCredentials(sess, config.RoleARN)
return ssm.New(sess, &aws.Config{Credentials: creds})
}
131 changes: 131 additions & 0 deletions secrets/provider_registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package secrets

import (
"encoding/json"
"fmt"
"sync"

"github.com/buildkite/agent/v3/bootstrap/shell"
"github.com/puzpuzpuz/xsync"
)

type ProviderRegistry struct {
config ProviderRegistryConfig
// We have the two maps here because we only want to initialise a provider if a secret using that provider is used
// We shouldn't boot up any secrets providers that won't get used
candidates *xsync.MapOf[string, providerCandidate] // Candidates technically doesn't need to be a sync map as it's never altered after initialization, but i've made it one just for symmetry
providers *xsync.MapOf[string, Provider]
}

type ProviderRegistryConfig struct {
Shell *shell.Shell
}

// NewProviderRegistryFromJSON takes a JSON string representing a slice of secrets.RawProvider, and returns a ProviderRegistry,
// ready to be used to fetch secrets.
func NewProviderRegistryFromJSON(config ProviderRegistryConfig, jsonIn string) (*ProviderRegistry, error) {
var rawProviders []providerCandidate
err := json.Unmarshal([]byte(jsonIn), &rawProviders)
if err != nil {
return nil, fmt.Errorf("unmarshalling secret providers: %w", err)
}

candidates := xsync.NewMapOf[providerCandidate]()
for _, provider := range rawProviders {
if _, ok := candidates.Load(provider.ID); ok {
return nil, fmt.Errorf("duplicate provider ID: %s. Provider IDs must be unique", provider.ID)
}

candidates.Store(provider.ID, provider)
}

return &ProviderRegistry{
config: config,
candidates: candidates,
providers: xsync.NewMapOf[Provider](),
}, nil
}

func (pr *ProviderRegistry) FetchAll(configs []SecretConfig) ([]Secret, []error) {
secrets := make([]Secret, 0, len(configs))
secretsCh := make(chan Secret)

errors := make([]error, 0, len(configs))
errorsCh := make(chan error)

var wg sync.WaitGroup
for _, c := range configs {
wg.Add(1)
go func(config SecretConfig) {
defer wg.Done()

secret, err := pr.Fetch(config)
if err != nil {

errorsCh <- err
return
}
secretsCh <- secret
}(c)
}

go func() {
for err := range errorsCh {
errors = append(errors, err)
}
}()

go func() {
for secret := range secretsCh {
secrets = append(secrets, secret)
}
}()

wg.Wait()
close(secretsCh)
close(errorsCh)

return secrets, errors
}

// Fetch takes a SecretConfig, and attempts to fetch it from the provider specified in the config.
// This method is goroutine-safe.
func (pr *ProviderRegistry) Fetch(config SecretConfig) (Secret, error) {
if provider, ok := pr.providers.Load(config.ProviderID); ok { // We've used this provider before, it's already been initialized
value, err := provider.Fetch(config.Key)
if err != nil {
return nil, fmt.Errorf("fetching secret %s from provider %s: %w", config.Key, config.ProviderID, err)
}

pr.config.Shell.Commentf("Secret %s fetched from provider %s", config.Key, config.ProviderID)
secret, err := newSecret(config, pr.config.Shell.Env, value)
if err != nil {
return nil, fmt.Errorf("creating secret %s from provider %s: %w", config.Key, config.ProviderID, err)
}
return secret, nil
}

if candidate, ok := pr.candidates.Load(config.ProviderID); ok { // We haven't used this provider yet, so we need to initialize it
provider, err := candidate.Initialize()
if err != nil {
return nil, fmt.Errorf("initializing provider %s (type: %s) to fetch secret %s: %w", config.ProviderID, candidate.Type, config.Key, err)
}

pr.providers.Store(config.ProviderID, provider) // Store the initialised provider

value, err := provider.Fetch(config.Key) // Now fetch the actual secret.
if err != nil {
return nil, fmt.Errorf("fetching secret %s from provider %s: %w", config.Key, config.ProviderID, err)
}

pr.config.Shell.Commentf("Secret %s fetched from provider %s", config.Key, config.ProviderID)
secret, err := newSecret(config, pr.config.Shell.Env, value)
if err != nil {
return nil, fmt.Errorf("creating secret %s from provider %s: %w", config.Key, config.ProviderID, err)
}
return secret, nil
}

// If we've got to this point, the user has tried to use a provider ID that's not in the registry, so we can't give them their secret
return nil, fmt.Errorf("no secret provider with ID: %s", config.ProviderID)
}
Loading

0 comments on commit 9028219

Please sign in to comment.