diff --git a/.gitignore b/.gitignore index fab03ec3..4d39e47f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ ckr.yaml -config.* +!*.go +*config*.json *.asc +*cloud-key-rotator* +key.json diff --git a/.goreleaser.yml b/.goreleaser.yml index 3013403b..a937778e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,13 +1,27 @@ -# .goreleaser.yml -archive: - format: binary +archives: + - format: binary + builds: + - binary-build + - format: zip + builds: + - lambda-build + files: + - none* + name_template: "{{ .ProjectName }}_{{ .Version }}_lambda" builds: - - main: ./ + - id: binary-build + main: ./ goos: - windows - darwin - linux goarch: - amd64 + - id: lambda-build + main: ./ + goos: + - linux + goarch: + - amd64 checksum: name_template: "{{ .ProjectName }}_checksums.txt" diff --git a/README.md b/README.md index 28bbe761..b5cdcc39 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,21 @@ This is a Golang program to assist with the reporting of Service Account key ages, and rotating said keys once they pass a specific age threshold. +The tool can update keys held in the following locations: + +* GitHub +* CircleCI +* K8S (GKE only) + +The tool is packaged as an executable file for native invocation, and as a zip + file for deployment as an AWS Lambda. + ## Install ### From Binary Releases -Darwin, Linux and Windows Binaries can be downloaded from the [Releases](https://github.com/ovotech/cloud-key-rotator/releases) page. +Darwin, Linux and Windows Binaries can be downloaded from the + [Releases](https://github.com/ovotech/cloud-key-rotator/releases) page. Try it out: @@ -15,10 +25,9 @@ Try it out: $ cloud-key-rotator -h ``` -### Docker image - -An alpine based Docker image is available [here](https://hub.docker.com/r/ovotech/cloud-key-rotator). +### Docker Image +An Alpine-based Docker image is available [here](https://hub.docker.com/r/ovotech/cloud-key-rotator). ## Getting Started @@ -28,10 +37,14 @@ An alpine based Docker image is available [here](https://hub.docker.com/r/ovotec to update with new keys, from config. Check out [examples](examples) for example config files. [Viper](https://github.com/spf13/viper) -is used as the Config framework, so config can be stored as JSON, TOML, YAML, or -HCL. To work, the file just needs to be called "config" (before whatever +is used as the config framework, so config can be stored as JSON, TOML, YAML or +HCL. + +For native invocation, the file needs to be called "config" (before whatever extension you're using), and be present either in `/etc/cloud-key-rotator/` or -in the same directory the binary runs in. +in the same directory the binary runs in. For AWS Lambda invocation, the config needs +to be set as a plaintext secret in the AWS Secrets Manager, using a default key name + of "ckr-config". ### Authentication/Authorisation @@ -39,14 +52,17 @@ You'll need to provide `cloud-key-rotator` with the means of authenticating into any key provider that it'll be updating. Authorisation is handled by the Default Credential Provider Chains for both -[GCP](https://cloud.google.com/docs/authentication/production#auth-cloud-implicit-go) and [AWS](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default). +[GCP](https://cloud.google.com/docs/authentication/production#auth-cloud-implicit-go) and + [AWS](https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/credentials.html#credentials-default). ### Mode Of Operation -`cloud-key-rotator` can operate in 2 different modes. Rotation mode, in which -keys are rotated, and non-rotation mode, which only posts the ages of the keys to -the Datadog metric API. You can specify which mode to operate in by using the -`RotationMode` boolean field in config. +`cloud-key-rotator` can operate in two different modes: + +1. Rotation mode - in which keys are rotated; and +2. Non-rotation mode - which only posts the ages of keys to the Datadog Metric API. + +The boolean field `RotationMode` config controls the mode of operation. ### Age Thresholds @@ -55,47 +71,43 @@ You can set the age threshold to whatever you want in the config, using the per-service-account-basis with the `RotationAgeThresholdMins` field. Key ages are always measured in minutes. -`cloud-key-rotator` won't attempt to rotate a key until it's passed the age +`cloud-key-rotator` will not attempt to rotate a key until it's passed the age threshold you've set (either default or the key-specific). This allows you to -run it as frequently as you want without worrying about keys being rotated too -much. +run it the tool frequently as you want without worrying about keys being rotated +too excessively. ### Key Locations -Key locations is the term used for the places that keys are stored, that -ultimately get updated with new keys that are generated. +"Key locations" is the term used for the places where keys are stored, which will +ultimately be updated with the new keys that are generated. Currently, the following locations are supported: -- EnvVars CircleCI -- Secrets in GKE -- Files (encrypted via [mantle](https://github.com/ovotech/mantle) which +* EnvVars CircleCI +* Secrets in GKE +* Files (encrypted via [mantle](https://github.com/ovotech/mantle) which integrates with KMS) in GitHub ## Rotation Process -By design, the rotation process is sensitive; it attempts to verify its actions -as much as possible, and aborts immediately if it sees any errors. The idea -behind this approach is that it should be quick to re-run the tool (with new -keys being created) once issues have been resolved. Cloud Providers usually -limit the number of keys you can have attached to a Service Account at any one -time, so worth bearing this in mind when re-running manually after seeing errors. +The tool attempts to verify its actions as much as possible and aborts +immediately if it encounters an error. By design, the tool does **not** attempt to +handle errors gracefully and continue, since this can lead to a "split-brain effect", +with keys out-of-sync in various locations. -The alternative to this, is for `cloud-key-rotator` to attempt to gracefully -handle errors, and for someone to manually patch things up afterwards. This -approach could lead to split-brain effect of key sources (places where the keys -are used) and confusion around what needs to be done before the old key can be -deleted. After all sources are using the new key, the same thing may happen on -the next key rotation, which doesn't lend itself well to automation. +It should be quick to re-run the tool (with new keys being created) once issues + have been resolved. Note that cloud providers usually limit the number of + keys you can have attached to a Service Account at any one time, so it is + worth bearing this in mind when re-running manually after seeing errors. -Only the first key of a Service Account is handled by `cloud-key-rotator`. If it -handled more than one key, it could lead to complications when updating single -sources multiple times. +Only the first key of a Service Account is handled by `cloud-key-rotator`. If +it handled more than one key, it could lead to complications when updating +single sources multiple times. ## Key Sources The `AccountKeyLocations` section of config holds details of the places where the keys -are stored. E.g.: +are stored, e.g.: ```JSON "AccountKeyLocations": [{ @@ -124,18 +136,18 @@ are stored. E.g.: `cloud-key-rotator` has integrations into GitHub and CircleCI, which allows it not only to update those sources with the new key, but also to verify that a -deployment has been successful after committing to a GitHub repo. If that +deployment has been successful after committing to a GitHub repository. If that verification isn't required, you can disable it using the `VerifyCircleCISuccess` boolean. -For any GitHub key source that's configured, the whole process will be aborted -if there's no `KmsKey` value set. Unencrypted keys shouldn't ever be committed +For any GitHub key location, the whole process will be aborted +if there is no `KmsKey` value set. Unencrypted keys should **never** be committed to a Git repository. ## GPG Commit Signing Commits to GitHub repositories are required to be GPG signed. In order to -achieve this, you just need to provide 4 things: +achieve this, you need to provide 4 things: * `Username` of the GitHub user commits will be made on behalf of, set in config * `Email` address of GitHub user, set in config @@ -180,18 +192,19 @@ Service Account's email address. ## Rotation Flow -- Reduce keys to those of service accounts deemed to be valid (e.g. strip out +1. Reduce keys to those of service accounts deemed to be valid (e.g. strip out user accounts if in rotation-mode) -- Filter keys to those deemed to be eligible (e.g. according to filtering rules +2. Filter keys to those deemed to be eligible (e.g. according to filtering rules configured by the user) -- For each eligible key: - - Create new key - - Update key locations - - Verify update has worked (where possible) - - Delete old key +3. For each eligible key: + + * Create new key + * Update key locations + * Verify update has worked (where possible) + * Delete old key ## Contributions -Contributions are more than welcome. It should be straight forward plugging in -new integrations of new key providers and/or locations, so for that, +Contributions are more than welcome. It should be straightforward plugging in +integrations of new key providers and/or locations, so for that, or anything else, please branch or fork and raise a PR. diff --git a/cmd/rotate.go b/cmd/rotate.go index 583f24be..974aa8f9 100644 --- a/cmd/rotate.go +++ b/cmd/rotate.go @@ -1,615 +1,49 @@ package cmd import ( - "bytes" - b64 "encoding/base64" - "errors" - "fmt" - "net/http" - "os" - "regexp" - "strconv" - "strings" - "time" - - keys "github.com/ovotech/cloud-key-client" - enc "github.com/ovotech/mantle/crypt" + "github.com/ovotech/cloud-key-rotator/pkg/config" + "github.com/ovotech/cloud-key-rotator/pkg/log" + "github.com/ovotech/cloud-key-rotator/pkg/rotate" "github.com/spf13/cobra" - "github.com/spf13/viper" - "go.uber.org/zap" - "golang.org/x/crypto/openpgp" - "golang.org/x/oauth2" - "k8s.io/client-go/rest" ) -//keyWriter defines the function signature for writing key to a location, e.g. CircleCI, K8S cluster or GitHub. -type keyWriter interface { - write(serviceAccountName, keyID, key string, creds credentials) (updatedLocation, error) -} - -//cloudProvider type -type cloudProvider struct { - Name string - Project string - Self string -} - -//datadog type -type datadog struct { - MetricEnv string - MetricTeam string - MetricName string -} - -//keyLocations type -type keyLocations struct { - RotationAgeThresholdMins int - ServiceAccountName string - CircleCI []circleCI - GitHub gitHub - K8s []k8s -} - -//updatedLocation type -type updatedLocation struct { - LocationType string - LocationURI string - LocationIDs []string -} - -//serviceAccount type -type providerServiceAccounts struct { - Provider cloudProvider - ProviderAccounts []string -} - -type credentials struct { - CircleCIAPIToken string - GitHubAccount gitHubAccount - AkrPass string - KmsKey string -} - -type filter struct { - Mode string - Accounts []providerServiceAccounts -} - -//config type -type config struct { - IncludeAwsUserKeys bool - Datadog datadog - DatadogAPIKey string - RotationMode bool - CloudProviders []cloudProvider - AccountFilter filter - AccountKeyLocations []keyLocations - Credentials credentials - DefaultRotationAgeThresholdMins int -} - -//googleAuthProvider type -type googleAuthProvider struct { - tokenSource oauth2.TokenSource -} - -//rotationCandidate type -type rotationCandidate struct { - key keys.Key - keyLocation keyLocations - rotationThresholdMins int -} - var ( - googleScopes = []string{ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/userinfo.email"} - _ rest.AuthProvider = &googleAuthProvider{} - rotateCmd = &cobra.Command{ + rotateCmd = &cobra.Command{ Use: "rotate", Short: "Rotate some cloud keys", Long: `Rotate some cloud keys`, Run: func(cmd *cobra.Command, args []string) { logger.Info("cloud-key-rotator rotate called") - if err := rotate(); err != nil { + var err error + var c config.Config + if c, err = config.GetConfig(defaultConfigPath); err == nil { + err = rotate.Rotate(account, provider, project, c) + } + if err != nil { logger.Error(err) } }, } - account string - provider string - project string - defaultAccount string - defaultProvider string - defaultProject string - logger = stdoutLogger().Sugar() -) - -const ( - datadogURL = "https://api.datadoghq.com/api/v1/series?api_key=" - envVarPrefix = "ckr" - googleAuthPlugin = "google" // so that this is different than "gcp" that's already in client-go tree. + account string + configPath string + defaultConfigPath = "/etc/cloud-key-rotator/" + provider string + project string + defaultAccount string + defaultProvider string + defaultProject string + logger = log.StdoutLogger().Sugar() ) func init() { rotateCmd.Flags().StringVarP(&account, "account", "a", defaultAccount, "Account to rotate") + rotateCmd.Flags().StringVarP(&configPath, "config", "c", defaultConfigPath, + "Absolute path of application config") rotateCmd.Flags().StringVarP(&provider, "provider", "p", defaultProvider, "Provider of account to rotate") rotateCmd.Flags().StringVarP(&project, "project", "j", defaultProject, "Project of account to rotate") rootCmd.AddCommand(rotateCmd) - if err := rest.RegisterAuthProviderPlugin(googleAuthPlugin, newGoogleAuthProvider); err != nil { - logger.Fatalf("Failed to register %s auth plugin: %v", googleAuthPlugin, err) - } -} - -//keyProviders returns a slice of key providers based on flags or config (in -// that order of priority) -func keyProviders(c config) (keyProviders []keys.Provider) { - if len(provider) > 0 { - keyProviders = append(keyProviders, keys.Provider{GcpProject: project, - Provider: provider}) - } else { - for _, cloudProvider := range c.CloudProviders { - keyProviders = append(keyProviders, keys.Provider{GcpProject: cloudProvider.Project, - Provider: cloudProvider.Name}) - } - } - return -} - -//validateFlags returns an error that's not nil if provided string values fail -// a set of validation rules -func validateFlags(account, provider, project string) (err error) { - if len(account) > 0 && len(provider) == 0 { - err = errors.New("Both account AND provider flags must be set") - return - } - if provider == "gcp" && len(project) == 0 { - err = errors.New("Project flag must be set when using the GCP provider") - return - } - return -} - -//keysOfProviders returns keys from all the configured providers that have passed -// through filtering -func keysOfProviders(c config) (accountKeys []keys.Key, err error) { - if accountKeys, err = keys.Keys(keyProviders(c)); err != nil { - return - } - logger.Infof("Found %d keys in total", len(accountKeys)) - return filterKeys(accountKeys, c, account) -} - -func rotate() (err error) { - defer logger.Sync() - var c config - if c, err = getConfig(); err != nil { - return - } - if err = validateFlags(account, provider, project); err != nil { - return - } - var providerKeys []keys.Key - if providerKeys, err = keysOfProviders(c); err != nil { - return - } - logger.Infof("Filtered down to %d keys based on current app config", len(providerKeys)) - if !c.RotationMode { - postMetric(providerKeys, c.DatadogAPIKey, c.Datadog) - return - } - var rc []rotationCandidate - if rc, err = rotationCandidates(providerKeys, c.AccountKeyLocations, - c.Credentials, c.DefaultRotationAgeThresholdMins); err != nil { - return - } - logger.Infof("Finalised %d keys that are candidates for rotation", len(rc)) - return rotateKeys(rc, c.Credentials) -} - -//getConfig returns the application config -func getConfig() (c config, err error) { - viper.AutomaticEnv() - viper.SetEnvPrefix(envVarPrefix) - viper.AddConfigPath("/etc/cloud-key-rotator/") - viper.SetEnvPrefix("ckr") - viper.SetConfigName("config") - viper.AddConfigPath(".") - viper.ReadInConfig() - if err = viper.Unmarshal(&c); err != nil { - return - } - if !viper.IsSet("cloudProviders") { - err = errors.New("cloudProviders is not set") - return - } - return -} - -//rotatekey creates a new key for the rotation candidate, updates its key locations, -// and deletes the old key iff the key location update is successful -func rotateKey(rotationCandidate rotationCandidate, creds credentials) (err error) { - key := rotationCandidate.key - keyProvider := key.Provider.Provider - var newKeyID string - var newKey string - if newKeyID, newKey, err = createKey(key, keyProvider); err != nil { - return - } - if err = updateKeyLocation(rotationCandidate.keyLocation, newKeyID, newKey, keyProvider, creds); err != nil { - return - } - return deleteKey(key, keyProvider) -} - -//rotationAgeThreshold calculates the key age rotation threshold based on config values -func rotationAgeThreshold(keyLocation keyLocations, defaultRotationAgeThresholdMins int) (rotationAgeThresholdMins int) { - rotationAgeThresholdMins = defaultRotationAgeThresholdMins - if keyLocation.RotationAgeThresholdMins > 0 { - rotationAgeThresholdMins = keyLocation.RotationAgeThresholdMins - } - return -} - -//rotateKeys iterates over the rotation candidates, invoking the func that actually -// performs the rotation -func rotateKeys(rotationCandidates []rotationCandidate, creds credentials) (err error) { - for _, rc := range rotationCandidates { - key := rc.key - logger.Infow("Rotation process started", - "keyProvider", key.Provider.Provider, - "account", account, - "keyID", key.ID, - "keyAge", fmt.Sprintf("%f", key.Age), - "keyAgeThreshold", strconv.Itoa(rc.rotationThresholdMins)) - - if err = rotateKey(rc, creds); err != nil { - return - } - } - - return -} - -//rotatekeys runs through the end to end process of rotating a slice of keys: -//filter down to subset of target keys, generate new key for each, update the -//key's locations and finally delete the existing/old key -func rotationCandidates(accountKeys []keys.Key, keyLoc []keyLocations, - creds credentials, defaultRotationAgeThresholdMins int) (rotationCandidates []rotationCandidate, err error) { - processedItems := make([]string, 0) - for _, key := range accountKeys { - keyAccount := key.Account - var locations keyLocations - - if locations, err = accountKeyLocation(keyAccount, keyLoc); err != nil { - return - } - - if contains(processedItems, key.FullAccount) { - logger.Infof("Skipping SA: %s, key: %s as a key for this account has already been added as a candidate for rotation", - account, key.ID) - continue - } - - rotationThresholdMins := rotationAgeThreshold(locations, defaultRotationAgeThresholdMins) - if float64(rotationThresholdMins) > key.Age { - logger.Infof("Skipping SA: %s, key: %s as it's only %f minutes old (threshold: %d mins)", - account, key.ID, key.Age, rotationThresholdMins) - continue - } - - rotationCandidates = append(rotationCandidates, rotationCandidate{key: key, - keyLocation: locations, - rotationThresholdMins: rotationThresholdMins}) - processedItems = append(processedItems, key.FullAccount) - } - - return -} - -//createKey creates a new key with the provider specified -func createKey(key keys.Key, keyProvider string) (newKeyID, newKey string, err error) { - if newKeyID, newKey, err = keys.CreateKey(key); err != nil { - logger.Error(err) - return - } - logger.Infow("New key created", - "keyProvider", keyProvider, - "account", account, - "keyID", newKeyID) - return -} - -//deletekey deletes the key -func deleteKey(key keys.Key, keyProvider string) (err error) { - if err = keys.DeleteKey(key); err != nil { - return - } - logger.Infow("Old key deleted", - "keyProvider", keyProvider, - "account", account, - "keyID", key.ID) - return -} - -//stdoutLogger creates a stdout logger -func stdoutLogger() (logger *zap.Logger) { - config := zap.NewProductionConfig() - config.OutputPaths = []string{"stdout"} - config.ErrorOutputPaths = []string{"stdout"} - logger, _ = config.Build() - return -} - -//accountKeyLocation gets the keyLocation element defined in config for the -//specified account -func accountKeyLocation(account string, - keyLocations []keyLocations) (accountKeyLocation keyLocations, err error) { - err = errors.New("No account key locations (in config) mapped to SA: " + account) - for _, keyLocation := range keyLocations { - if account == keyLocation.ServiceAccountName { - err = nil - accountKeyLocation = keyLocation - break - } - } - return -} - -//locationsToUpdate return a slice of structs that implement the keyWriter -// interface, based on the keyLocations supplied -func locationsToUpdate(keyLocation keyLocations) (kws []keyWriter) { - - // read locations - for _, circleCI := range keyLocation.CircleCI { - kws = append(kws, circleCI) - } - - if len(keyLocation.GitHub.OrgRepo) > 0 { - kws = append(kws, keyLocation.GitHub) - } - - for _, k8s := range keyLocation.K8s { - kws = append(kws, k8s) - } - - return -} - -//updateKeyLocation updates locations specified in keyLocations with the new key, e.g. GitHub, CircleCI an K8s -func updateKeyLocation(keyLocations keyLocations, keyID, key, keyProvider string, creds credentials) (err error) { - - // update locations - var updatedLocations []updatedLocation - - for _, location := range locationsToUpdate(keyLocations) { - - var updated updatedLocation - - if updated, err = location.write(keyLocations.ServiceAccountName, keyID, key, creds); err != nil { - return - } - - updatedLocations = append(updatedLocations, updated) - } - - // all done - logger.Infow("Key locations updated", - "keyProvider", keyProvider, - "account", account, - "keyID", keyID, - "keyLocationUpdates", updatedLocations) - - return -} - -//encryptedServiceAccountKey uses github.com/ovotech/mantle to encrypt the -// key string that's passed in -func encryptedServiceAccountKey(key, kmsKey string) (encKey []byte, err error) { - const singleLine = false - const disableValidation = true - - var decodedKey []byte - if decodedKey, err = b64.StdEncoding.DecodeString(key); err != nil { - return - } - - return enc.CipherBytesFromPrimitives([]byte(decodedKey), singleLine, disableValidation, "", "", "", "", kmsKey), nil -} - -//validKey returns a bool reflecting whether the key is deemed to be valid, based -// on a number of provider-specific rules. E.g., if the provider is AWS, and -// not configured to include user keys, is the key a user key (and hence invalid)? -func validKey(key keys.Key, config config) bool { - if key.Provider.Provider == "aws" { - return validAwsKey(key, config) - } - return true -} -//filterKeys returns a keys.Key slice created by filtering the provided -// keys.Key slice based on specific rules for each provider -func filterKeys(keysToFilter []keys.Key, config config, account string) (filteredKeys []keys.Key, err error) { - var selfKeys []keys.Key - for _, key := range keysToFilter { - //valid bool is used to filter out keys early, e.g. if config says don't - //include AWS user keys, and the current key happens to be a user key - if !validKey(key, config) { - continue - } - var eligible bool - if eligible, err = filterKey(account, config, key); err != nil { - return - } - if eligible { - //don't add the key to filteredKeys yet if it's deemed to be a 'self' key - // (i.e. the key belongs to the process performing this rotation) - if isSelf(config, key) { - logger.Infow("Key has been identified as a cloud-rotator key, so will be processed last", - "keyProvider", key.Provider, - "account", key.Account) - selfKeys = append(selfKeys, key) - } else { - filteredKeys = append(filteredKeys, key) - } - } - } - //now add the 'self' keys - filteredKeys = append(filteredKeys, selfKeys...) - return -} - -//isSelf returns true iff the key provided matches the 'self' defined in the -// config.cloudProvider. This means the key is the one being used in the -// rotation process, and should probably be rotated last. -func isSelf(config config, key keys.Key) bool { - for _, cloudProvider := range config.CloudProviders { - if cloudProvider.Name == key.Provider.Provider && - cloudProvider.Project == key.Provider.GcpProject && - cloudProvider.Self == key.Account { - return true - } - } - return false -} - -//filterKey returns a bool indicating whether the key is eligible for 'use' -func filterKey(account string, config config, key keys.Key) (eligible bool, err error) { - if len(account) > 0 { - //this means an overriding account has been supplied, i.e. from CLI - eligible = key.Account == account - } else if !config.RotationMode { - //rotation mode is false, so include the key so its age can be used - eligible = true - } else { - if eligible, err = isKeyEligible(config, key); err != nil { - return - } - } - return -} - -//isKeyEligible returns a bool indicating whether the key is eligible based on -// application config -func isKeyEligible(config config, key keys.Key) (eligible bool, err error) { - filterAccounts := config.AccountFilter.Accounts - filterMode := config.AccountFilter.Mode - switch filterMode { - case "include": - eligible = keyDefinedInFiltering(filterAccounts, key) - case "exclude": - eligible = !keyDefinedInFiltering(filterAccounts, key) - default: - err = fmt.Errorf("Filter mode: %s is not supported", filterMode) - } - return -} - -//keyDefinedInFiltering returns a bool indicating whether the key matches -// a service account defined in the AccountFilter -func keyDefinedInFiltering(providerServiceAccounts []providerServiceAccounts, - key keys.Key) bool { - for _, psa := range providerServiceAccounts { - if psa.Provider.Name == key.Provider.Provider && - psa.Provider.Project == key.Provider.GcpProject { - for _, sa := range psa.ProviderAccounts { - if sa == key.Account { - return true - } - } - } - } - - return false -} - -//contains returns true if the string slice contains the specified string -func contains(s []string, e string) bool { - for _, a := range s { - if a == e { - return true - } - } - return false -} - -//validAwsKey returns a bool that reflects whether the provided keys.Key is -// valid, based on aws-specific rules -func validAwsKey(key keys.Key, config config) (valid bool) { - if config.IncludeAwsUserKeys { - valid = true - } else { - match, _ := regexp.MatchString("[a-zA-Z]\\.[a-zA-Z]", key.Name) - valid = !match - } - return -} - -//postMetric posts details of each keys.Key to a metrics api -func postMetric(keys []keys.Key, apiKey string, datadog datadog) (err error) { - if len(apiKey) > 0 { - url := strings.Join([]string{datadogURL, apiKey}, "") - for _, key := range keys { - var jsonString = []byte( - `{ "series" :[{"metric":"` + datadog.MetricName + `",` + - `"points":[[` + - strconv.FormatInt(time.Now().Unix(), 10) + - `, ` + strconv.FormatFloat(key.Age, 'f', 2, 64) + - `]],` + - `"type":"count",` + - `"tags":[` + - `"team:` + datadog.MetricTeam + `",` + - `"environment:` + datadog.MetricEnv + `",` + - `"key:` + key.Name + `",` + - `"provider:` + key.Provider.Provider + `",` + - `"account:` + key.Account + - `"]}]}`) - var req *http.Request - if req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonString)); err != nil { - return - } - req.Header.Set("Content-type", "application/json") - client := &http.Client{} - var resp *http.Response - if resp, err = client.Do(req); err != nil { - return - } - defer resp.Body.Close() - if resp.StatusCode != 202 { - err = fmt.Errorf("non-202 status code (%d) returned by Datadog", resp.StatusCode) - } - } - } - return -} - -//commitSignKey creates an openPGP Entity based on a user's name, email, -//armoredKeyRing and passphrase for the key ring. This commitSignKey can then -//be used to GPG sign Git commits -func commitSignKey(name, email, passphrase string) (entity *openpgp.Entity, - err error) { - if passphrase == "" { - err = errors.New("ArmouredKeyRing passphrase must not be empty") - return - } - var reader *os.File - if reader, err = os.Open("/etc/cloud-key-rotator/akr.asc"); err != nil { - return - } - var entityList openpgp.EntityList - if entityList, err = openpgp.ReadArmoredKeyRing(reader); err != nil { - return - } - _, ok := entityList[0].Identities[strings.Join([]string{name, " <", email, ">"}, "")] - if !ok { - err = errors.New("Failed to add Identity to EntityList") - } - if err = entityList[0].PrivateKey.Decrypt([]byte(passphrase)); err != nil { - return - } - entity = entityList[0] - return } diff --git a/examples/circleci/README.md b/examples/circleci/README.md new file mode 100644 index 00000000..32a44603 --- /dev/null +++ b/examples/circleci/README.md @@ -0,0 +1,40 @@ +# CircleCI Example + +## Pre-requisites + +In order to enable rotation + update of a secret that's stored in CircleCI +env vars, you'll need: + +1. A GitHub user (preferably a dedicated machine-user, rather than a human's) +with write access to the GitHub repository that the CircleCI project is linked to. +2. A CircleCI API key for the GitHub user, which can be generated by logging in +to [circleci.com](circleci.com) as the user, then creating a [personal API token](https://circleci.com/account/api). +3. Auth to actually perform the rotation operation with whichever cloud provider +you're using. This will require a service-account or user (with the cloud-provider you're rotating with) that has the required set of permissions. Then, auth will +need to be given to `cloud-key-rotator` (usually in the form of a .json file or +env vars). + +## Configuration + +In config, you may be setting something like this: + +```json +"CircleCI": [{ + "UsernameProject": "ovotech/project_name", + "KeyIDEnvVar": "AWS_ACCESS_KEY_ID", + "KeyEnvVar": "AWS_SECRET_ACCESS_KEY" +}] +``` + +This example is specific to AWS, for which you'll be storing both the access key +ID and the secret access Key. + +For other providers, where you may only wish to store the key itself (and not +the ID), you can omit the `KeyIDEnvVar` field. E.g.: + +```json +"CircleCI": [{ + "UsernameProject": "ovotech/project_name", + "KeyEnvVar": "GCP_KEY_JSON" +}] +``` diff --git a/examples/circleci/rotate-aws-circleci.json b/examples/circleci/rotate-aws-circleci.json new file mode 100644 index 00000000..dc9e7c98 --- /dev/null +++ b/examples/circleci/rotate-aws-circleci.json @@ -0,0 +1,30 @@ +{ + "RotationMode": true, + "CloudProviders": [{ + "Name": "aws", + "Self": "" + }], + "AccountFilter": { + "Mode": "include", + "Accounts": [{ + "Provider": { + "Name": "aws" + }, + "ProviderAccounts": [ + "aws-circleci-user" + ] + }] + }, + "AccountKeyLocations": [{ + "ServiceAccountName": "aws-circleci-user", + "CircleCI": [{ + "UsernameProject": "ovotech/project_name", + "KeyIDEnvVar": "AWS_ACCESS_KEY_ID", + "KeyEnvVar": "AWS_SECRET_ACCESS_KEY" + }] + }], + "Credentials": { + "CircleCIAPIToken": "**change_me**" + }, + "DefaultRotationAgeThresholdMins": 5 +} diff --git a/examples/circleci/rotate-gcp-circleci.json b/examples/circleci/rotate-gcp-circleci.json new file mode 100644 index 00000000..c0945fa3 --- /dev/null +++ b/examples/circleci/rotate-gcp-circleci.json @@ -0,0 +1,31 @@ +{ + "RotationMode": true, + "CloudProviders": [{ + "Name": "gcp", + "Project": "my_gcp_project", + "Self": "" + }], + "AccountFilter": { + "Mode": "include", + "Accounts": [{ + "Provider": { + "Name": "gcp", + "Project": "my_gcp_project" + }, + "ProviderAccounts": [ + "gcp-circleci-user" + ] + }] + }, + "AccountKeyLocations": [{ + "ServiceAccountName": "gcp-circleci-user", + "CircleCI": [{ + "UsernameProject": "ovotech/project_name", + "KeyEnvVar": "GCP_KEY_JSON" + }] + }], + "Credentials": { + "CircleCIAPIToken": "**change_me**" + }, + "DefaultRotationAgeThresholdMins": 5 +} diff --git a/main.go b/main.go index e8d1bf90..a058d272 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,54 @@ package main import ( + "context" + "os" + + "github.com/aws/aws-lambda-go/lambda" "github.com/ovotech/cloud-key-rotator/cmd" + "github.com/ovotech/cloud-key-rotator/pkg/config" + "github.com/ovotech/cloud-key-rotator/pkg/rotate" ) +// MyEvent type +type MyEvent struct { + Name string `json:"name"` +} + +//HandleRequest allows cloud-key-rotator to be used in the Lambda program model +func HandleRequest(ctx context.Context, name MyEvent) (string, error) { + var c config.Config + var err error + status := "fail" + if c, err = config.GetConfigFromAWSSecretManager( + getEnv("CKR_SECRET_CONFIG_NAME", "ckr-config"), + getEnv("CKR_CONFIG_TYPE", "json")); err != nil { + return status, err + } + if err = rotate.Rotate("", "", "", c); err == nil { + status = "success" + } + return status, err +} + func main() { - cmd.Execute() + if isLambda() { + lambda.Start(HandleRequest) + } else { + cmd.Execute() + } +} + +//isLambda returns true if the AWS_LAMBDA_FUNCTION_NAME env var is set +func isLambda() (isLambda bool) { + return len(os.Getenv("AWS_LAMBDA_FUNCTION_NAME")) > 0 +} + +//getEnv returns the value of the env var matching the key, if it exists, and +// the value of fallback otherwise +func getEnv(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback } diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 00000000..c6faf9e1 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,113 @@ +package config + +import ( + "bytes" + "errors" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/ovotech/cloud-key-rotator/pkg/cred" + "github.com/ovotech/cloud-key-rotator/pkg/location" + "github.com/spf13/viper" +) + +//Config type +type Config struct { + IncludeAwsUserKeys bool + Datadog Datadog + DatadogAPIKey string + RotationMode bool + CloudProviders []CloudProvider + AccountFilter Filter + AccountKeyLocations []KeyLocations + Credentials cred.Credentials + DefaultRotationAgeThresholdMins int +} + +//CloudProvider type +type CloudProvider struct { + Name string + Project string + Self string +} + +//Datadog type +type Datadog struct { + MetricEnv string + MetricTeam string + MetricName string +} + +//Filter type +type Filter struct { + Mode string + Accounts []ProviderServiceAccounts +} + +//KeyLocations type +type KeyLocations struct { + RotationAgeThresholdMins int + ServiceAccountName string + CircleCI []location.CircleCI + GitHub location.GitHub + K8s []location.K8s +} + +//ProviderServiceAccounts type +type ProviderServiceAccounts struct { + Provider CloudProvider + ProviderAccounts []string +} + +const envVarPrefix = "ckr" + +//GetConfig returns the application config +func GetConfig(configPath string) (c Config, err error) { + viper.AutomaticEnv() + viper.SetEnvPrefix(envVarPrefix) + viper.AddConfigPath(configPath) + viper.SetEnvPrefix("ckr") + viper.SetConfigName("config") + viper.AddConfigPath(".") + viper.ReadInConfig() + if err = viper.Unmarshal(&c); err != nil { + return + } + if !viper.IsSet("cloudProviders") { + err = errors.New("cloudProviders is not set") + return + } + return +} + +// GetConfigFromAWSSecretManager grabs the cloud-key-rotator's config from +// AWS Secret Manager +func GetConfigFromAWSSecretManager(secretName, configType string) (c Config, err error) { + var secret string + if secret, err = getSecret(secretName); err != nil { + return + } + if len(secret) == 0 { + return c, errors.New("Unable to obtain secret value. Check user permissions and secret name") + } + viper.SetConfigType(configType) + viper.ReadConfig(bytes.NewBufferString(secret)) + err = viper.Unmarshal(&c) + return +} + +func getSecret(secretName string) (secretString string, err error) { + //Create a Secrets Manager client + svc := secretsmanager.New(session.New()) + input := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + VersionStage: aws.String("AWSCURRENT"), // VersionStage defaults to AWSCURRENT if unspecified + } + var result *secretsmanager.GetSecretValueOutput + if result, err = svc.GetSecretValue(input); err != nil { + return + } + secretString = *result.SecretString + return +} diff --git a/pkg/cred/creds.go b/pkg/cred/creds.go new file mode 100644 index 00000000..fb34bce5 --- /dev/null +++ b/pkg/cred/creds.go @@ -0,0 +1,16 @@ +package cred + +// Credentials type +type Credentials struct { + CircleCIAPIToken string + GitHubAccount GitHubAccount + AkrPass string + KmsKey string +} + +// GitHubAccount type +type GitHubAccount struct { + GitHubAccessToken string + GitName string + GitEmail string +} diff --git a/pkg/crypt/crypt.go b/pkg/crypt/crypt.go new file mode 100644 index 00000000..c67cc5a3 --- /dev/null +++ b/pkg/crypt/crypt.go @@ -0,0 +1,56 @@ +package crypt + +import ( + b64 "encoding/base64" + "errors" + "os" + "strings" + + enc "github.com/ovotech/mantle/crypt" + "golang.org/x/crypto/openpgp" +) + +//EncryptedServiceAccountKey uses github.com/ovotech/mantle to encrypt the +// key string that's passed in +func EncryptedServiceAccountKey(key, kmsKey string) (encKey []byte, err error) { + const singleLine = false + const disableValidation = true + + var decodedKey []byte + if decodedKey, err = b64.StdEncoding.DecodeString(key); err != nil { + return + } + + return enc.CipherBytesFromPrimitives([]byte(decodedKey), singleLine, + disableValidation, "", "", "", "", kmsKey), nil +} + +//CommitSignKey creates an openPGP Entity based on a user's name, email, +//armoredKeyRing and passphrase for the key ring. This commitSignKey can then +//be used to GPG sign Git commits +func CommitSignKey(name, email, passphrase string) (entity *openpgp.Entity, + err error) { + if passphrase == "" { + err = errors.New("ArmouredKeyRing passphrase must not be empty") + return + } + var reader *os.File + if reader, err = os.Open("/etc/cloud-key-rotator/akr.asc"); err != nil { + if reader, err = os.Open("./akr.asc"); err != nil { + return + } + } + var entityList openpgp.EntityList + if entityList, err = openpgp.ReadArmoredKeyRing(reader); err != nil { + return + } + _, ok := entityList[0].Identities[strings.Join([]string{name, " <", email, ">"}, "")] + if !ok { + err = errors.New("Failed to add Identity to EntityList") + } + if err = entityList[0].PrivateKey.Decrypt([]byte(passphrase)); err != nil { + return + } + entity = entityList[0] + return +} diff --git a/cmd/circleci.go b/pkg/location/circleci.go similarity index 93% rename from cmd/circleci.go rename to pkg/location/circleci.go index 5be6ba55..1b104f1b 100644 --- a/cmd/circleci.go +++ b/pkg/location/circleci.go @@ -1,4 +1,4 @@ -package cmd +package location import ( "fmt" @@ -6,18 +6,22 @@ import ( "time" circleci "github.com/jszwedko/go-circleci" + "github.com/ovotech/cloud-key-rotator/pkg/cred" + "github.com/ovotech/cloud-key-rotator/pkg/log" ) -//circleCI type -type circleCI struct { +//CircleCI type +type CircleCI struct { UsernameProject string KeyIDEnvVar string KeyEnvVar string } +var logger = log.StdoutLogger().Sugar() + //updateCircleCI updates the circleCI environment variable by deleting and //then creating it again with the new key -func (circle circleCI) write(serviceAccountName, keyID, key string, creds credentials) (updated updatedLocation, err error) { +func (circle CircleCI) Write(serviceAccountName, keyID, key string, creds cred.Credentials) (updated UpdatedLocation, err error) { logger.Info("Starting CircleCI env var updates") client := &circleci.Client{Token: creds.CircleCIAPIToken} keyIDEnvVarName := circle.KeyIDEnvVar @@ -35,7 +39,7 @@ func (circle circleCI) write(serviceAccountName, keyID, key string, creds creden return } - updated = updatedLocation{ + updated = UpdatedLocation{ LocationType: "CircleCI", LocationURI: circle.UsernameProject, LocationIDs: []string{circle.KeyIDEnvVar, circle.KeyEnvVar}} diff --git a/cmd/git.go b/pkg/location/github.go similarity index 80% rename from cmd/git.go rename to pkg/location/github.go index 4d4e5770..b46716e8 100644 --- a/cmd/git.go +++ b/pkg/location/github.go @@ -1,4 +1,4 @@ -package cmd +package location import ( "errors" @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/ovotech/cloud-key-rotator/pkg/cred" + "github.com/ovotech/cloud-key-rotator/pkg/crypt" "golang.org/x/crypto/openpgp" git "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" @@ -15,22 +17,15 @@ import ( gitHttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http" ) -// gitHubAccount type -type gitHubAccount struct { - GitHubAccessToken string - GitName string - GitEmail string -} - -//gitHub type -type gitHub struct { +//GitHub type +type GitHub struct { Filepath string OrgRepo string VerifyCircleCISuccess bool CircleCIDeployJobName string } -func (gitHub gitHub) write(serviceAccountName, keyID, key string, creds credentials) (updated updatedLocation, err error) { +func (gitHub GitHub) Write(serviceAccountName, keyID, key string, creds cred.Credentials) (updated UpdatedLocation, err error) { if len(creds.KmsKey) == 0 { err = errors.New("Not updating un-encrypted new key in a Git repository. Use the" + @@ -38,18 +33,20 @@ func (gitHub gitHub) write(serviceAccountName, keyID, key string, creds credenti return } + // const localDir = "/etc/cloud-key-rotator/cloud-key-rotator-tmp-repo" + const localDir = "/etc/cloud-key-rotator/cloud-key-rotator-tmp-repo" defer os.RemoveAll(localDir) // TODO Move me out of git-specific code var encKey []byte - if encKey, err = encryptedServiceAccountKey(key, creds.KmsKey); err != nil { + if encKey, err = crypt.EncryptedServiceAccountKey(key, creds.KmsKey); err != nil { return } var signKey *openpgp.Entity - if signKey, err = commitSignKey(creds.GitHubAccount.GitName, creds.GitHubAccount.GitEmail, creds.AkrPass); err != nil { + if signKey, err = crypt.CommitSignKey(creds.GitHubAccount.GitName, creds.GitHubAccount.GitEmail, creds.AkrPass); err != nil { return } @@ -64,7 +61,7 @@ func (gitHub gitHub) write(serviceAccountName, keyID, key string, creds credenti gitHub.CircleCIDeployJobName, creds.CircleCIAPIToken) } - updated = updatedLocation{ + updated = UpdatedLocation{ LocationType: "GitHub", LocationURI: gitHub.OrgRepo, LocationIDs: []string{gitHub.Filepath}} @@ -74,8 +71,8 @@ func (gitHub gitHub) write(serviceAccountName, keyID, key string, creds credenti //writeKeyToRemoteGitRepo handles the writing of the supplied key to the *remote* // Git repo defined in the GitHub struct -func writeKeyToRemoteGitRepo(gitHub gitHub, serviceAccountName string, - key []byte, localDir string, signKey *openpgp.Entity, creds credentials) (committed *object.Commit, err error) { +func writeKeyToRemoteGitRepo(gitHub GitHub, serviceAccountName string, + key []byte, localDir string, signKey *openpgp.Entity, creds cred.Credentials) (committed *object.Commit, err error) { var repo *git.Repository if repo, err = cloneGitRepo(localDir, gitHub.OrgRepo, creds.GitHubAccount.GitHubAccessToken); err != nil { @@ -104,8 +101,8 @@ func writeKeyToRemoteGitRepo(gitHub gitHub, serviceAccountName string, //writeKeyToLocalGitRepo handles the writing of the supplied key to the *local* // Git repo defined in the GitHub struct -func writeKeyToLocalGitRepo(gitHub gitHub, repo *git.Repository, key []byte, - serviceAccountName, localDir string, signKey *openpgp.Entity, creds credentials) (commmit plumbing.Hash, err error) { +func writeKeyToLocalGitRepo(gitHub GitHub, repo *git.Repository, key []byte, + serviceAccountName, localDir string, signKey *openpgp.Entity, creds cred.Credentials) (commmit plumbing.Hash, err error) { var w *git.Worktree if w, err = repo.Worktree(); err != nil { return diff --git a/cmd/k8s.go b/pkg/location/k8s.go similarity index 80% rename from cmd/k8s.go rename to pkg/location/k8s.go index 98c2ea94..4080b6bf 100644 --- a/cmd/k8s.go +++ b/pkg/location/k8s.go @@ -1,4 +1,4 @@ -package cmd +package location import ( "context" @@ -6,6 +6,7 @@ import ( "fmt" "net/http" + "github.com/ovotech/cloud-key-rotator/pkg/cred" "golang.org/x/oauth2" "golang.org/x/oauth2/google" gkev1 "google.golang.org/api/container/v1" @@ -16,8 +17,8 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" ) -//k8s type -type k8s struct { +//K8s type +type K8s struct { Project string Location string ClusterName string @@ -26,7 +27,28 @@ type k8s struct { DataName string } -func (k8s k8s) write(serviceAccountName, keyID, key string, creds credentials) (updated updatedLocation, err error) { +//googleAuthProvider type +type googleAuthProvider struct { + tokenSource oauth2.TokenSource +} + +var ( + googleScopes = []string{ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email"} + _ rest.AuthProvider = &googleAuthProvider{} + // logger = log.StdoutLogger() +) + +const googleAuthPlugin = "google" // so that this is different than "gcp" that's already in client-go tree. + +func init() { + if err := rest.RegisterAuthProviderPlugin(googleAuthPlugin, newGoogleAuthProvider); err != nil { + logger.Fatalf("Failed to register %s auth plugin: %v", googleAuthPlugin, err) + } +} + +func (k8s K8s) Write(serviceAccountName, keyID, key string, creds cred.Credentials) (updated UpdatedLocation, err error) { var cluster *gkev1.Cluster if cluster, err = gkeCluster(k8s.Project, k8s.Location, k8s.ClusterName); err != nil { @@ -42,7 +64,7 @@ func (k8s k8s) write(serviceAccountName, keyID, key string, creds credentials) ( return } - updated = updatedLocation{ + updated = UpdatedLocation{ LocationType: "K8S", LocationURI: k8s.Project, LocationIDs: []string{k8s.Location}} diff --git a/pkg/location/keywriter.go b/pkg/location/keywriter.go new file mode 100644 index 00000000..6a81657e --- /dev/null +++ b/pkg/location/keywriter.go @@ -0,0 +1,9 @@ +package location + +//keyWriter defines the function signature for writing key to a location, e.g. CircleCI, K8S cluster or GitHub. +import "github.com/ovotech/cloud-key-rotator/pkg/cred" + +//KeyWriter interface +type KeyWriter interface { + Write(serviceAccountName, keyID, key string, creds cred.Credentials) (UpdatedLocation, error) +} diff --git a/pkg/location/locations.go b/pkg/location/locations.go new file mode 100644 index 00000000..005d7772 --- /dev/null +++ b/pkg/location/locations.go @@ -0,0 +1,8 @@ +package location + +//UpdatedLocation type +type UpdatedLocation struct { + LocationType string + LocationURI string + LocationIDs []string +} diff --git a/pkg/log/log.go b/pkg/log/log.go new file mode 100644 index 00000000..74555c04 --- /dev/null +++ b/pkg/log/log.go @@ -0,0 +1,12 @@ +package log + +import "go.uber.org/zap" + +//StdoutLogger creates a stdout logger +func StdoutLogger() (logger *zap.Logger) { + config := zap.NewProductionConfig() + config.OutputPaths = []string{"stdout"} + config.ErrorOutputPaths = []string{"stdout"} + logger, _ = config.Build() + return +} diff --git a/pkg/rotate/rotatekeys.go b/pkg/rotate/rotatekeys.go new file mode 100644 index 00000000..78f78d41 --- /dev/null +++ b/pkg/rotate/rotatekeys.go @@ -0,0 +1,430 @@ +package rotate + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + keys "github.com/ovotech/cloud-key-client" + + "github.com/ovotech/cloud-key-rotator/pkg/config" + "github.com/ovotech/cloud-key-rotator/pkg/cred" + "github.com/ovotech/cloud-key-rotator/pkg/location" + "github.com/ovotech/cloud-key-rotator/pkg/log" +) + +//rotationCandidate type +type rotationCandidate struct { + key keys.Key + keyLocation config.KeyLocations + rotationThresholdMins int +} + +var logger = log.StdoutLogger().Sugar() + +const ( + datadogURL = "https://api.datadoghq.com/api/v1/series?api_key=" +) + +//keyProviders returns a slice of key providers based on flags or config (in +// that order of priority) +func keyProviders(provider, project string, c config.Config) (keyProviders []keys.Provider) { + if len(provider) > 0 { + keyProviders = append(keyProviders, keys.Provider{GcpProject: project, + Provider: provider}) + } else { + for _, cloudProvider := range c.CloudProviders { + keyProviders = append(keyProviders, keys.Provider{GcpProject: cloudProvider.Project, + Provider: cloudProvider.Name}) + } + } + return +} + +//validateFlags returns an error that's not nil if provided string values fail +// a set of validation rules +func validateFlags(account, provider, project string) (err error) { + if len(account) > 0 && len(provider) == 0 { + err = errors.New("Both account AND provider flags must be set") + return + } + if provider == "gcp" && len(project) == 0 { + err = errors.New("Project flag must be set when using the GCP provider") + return + } + return +} + +//keysOfProviders returns keys from all the configured providers that have passed +// through filtering +func keysOfProviders(account, provider, project string, c config.Config) (accountKeys []keys.Key, err error) { + if accountKeys, err = keys.Keys(keyProviders(provider, project, c)); err != nil { + return + } + logger.Infof("Found %d keys in total", len(accountKeys)) + return filterKeys(accountKeys, c, account) +} + +//Rotate rotates those keys.. +func Rotate(account, provider, project string, c config.Config) (err error) { + defer logger.Sync() + + if err = validateFlags(account, provider, project); err != nil { + return + } + var providerKeys []keys.Key + if providerKeys, err = keysOfProviders(account, provider, project, c); err != nil { + return + } + logger.Infof("Filtered down to %d keys based on current app config", len(providerKeys)) + if !c.RotationMode { + postMetric(providerKeys, c.DatadogAPIKey, c.Datadog) + return + } + var rc []rotationCandidate + if rc, err = rotationCandidates(account, providerKeys, c.AccountKeyLocations, + c.Credentials, c.DefaultRotationAgeThresholdMins); err != nil { + return + } + logger.Infof("Finalised %d keys that are candidates for rotation", len(rc)) + return rotateKeys(account, rc, c.Credentials) +} + +//rotatekey creates a new key for the rotation candidate, updates its key locations, +// and deletes the old key iff the key location update is successful +func rotateKey(account string, rotationCandidate rotationCandidate, creds cred.Credentials) (err error) { + key := rotationCandidate.key + keyProvider := key.Provider.Provider + var newKeyID string + var newKey string + if newKeyID, newKey, err = createKey(account, key, keyProvider); err != nil { + return + } + if err = updateKeyLocation(account, rotationCandidate.keyLocation, newKeyID, newKey, keyProvider, creds); err != nil { + return + } + return deleteKey(account, key, keyProvider) +} + +//rotationAgeThreshold calculates the key age rotation threshold based on config values +func rotationAgeThreshold(keyLocation config.KeyLocations, defaultRotationAgeThresholdMins int) (rotationAgeThresholdMins int) { + rotationAgeThresholdMins = defaultRotationAgeThresholdMins + if keyLocation.RotationAgeThresholdMins > 0 { + rotationAgeThresholdMins = keyLocation.RotationAgeThresholdMins + } + return +} + +//rotateKeys iterates over the rotation candidates, invoking the func that actually +// performs the rotation +func rotateKeys(account string, rotationCandidates []rotationCandidate, creds cred.Credentials) (err error) { + for _, rc := range rotationCandidates { + key := rc.key + logger.Infow("Rotation process started", + "keyProvider", key.Provider.Provider, + "account", account, + "keyID", key.ID, + "keyAge", fmt.Sprintf("%f", key.Age), + "keyAgeThreshold", strconv.Itoa(rc.rotationThresholdMins)) + + if err = rotateKey(account, rc, creds); err != nil { + return + } + } + + return +} + +//rotatekeys runs through the end to end process of rotating a slice of keys: +//filter down to subset of target keys, generate new key for each, update the +//key's locations and finally delete the existing/old key +func rotationCandidates(account string, accountKeys []keys.Key, keyLoc []config.KeyLocations, + creds cred.Credentials, defaultRotationAgeThresholdMins int) (rotationCandidates []rotationCandidate, err error) { + processedItems := make([]string, 0) + for _, key := range accountKeys { + keyAccount := key.Account + var locations config.KeyLocations + + if locations, err = accountKeyLocation(keyAccount, keyLoc); err != nil { + return + } + + if contains(processedItems, key.FullAccount) { + logger.Infof("Skipping SA: %s, key: %s as a key for this account has already been added as a candidate for rotation", + account, key.ID) + continue + } + + rotationThresholdMins := rotationAgeThreshold(locations, defaultRotationAgeThresholdMins) + if float64(rotationThresholdMins) > key.Age { + logger.Infof("Skipping SA: %s, key: %s as it's only %f minutes old (threshold: %d mins)", + account, key.ID, key.Age, rotationThresholdMins) + continue + } + + rotationCandidates = append(rotationCandidates, rotationCandidate{key: key, + keyLocation: locations, + rotationThresholdMins: rotationThresholdMins}) + processedItems = append(processedItems, key.FullAccount) + } + + return +} + +//createKey creates a new key with the provider specified +func createKey(account string, key keys.Key, keyProvider string) (newKeyID, newKey string, err error) { + if newKeyID, newKey, err = keys.CreateKey(key); err != nil { + logger.Error(err) + return + } + logger.Infow("New key created", + "keyProvider", keyProvider, + "account", account, + "keyID", newKeyID) + return +} + +//deletekey deletes the key +func deleteKey(account string, key keys.Key, keyProvider string) (err error) { + if err = keys.DeleteKey(key); err != nil { + return + } + logger.Infow("Old key deleted", + "keyProvider", keyProvider, + "account", account, + "keyID", key.ID) + return +} + +//accountKeyLocation gets the keyLocation element defined in config for the +//specified account +func accountKeyLocation(account string, + keyLocations []config.KeyLocations) (accountKeyLocation config.KeyLocations, err error) { + err = errors.New("No account key locations (in config) mapped to SA: " + account) + for _, keyLocation := range keyLocations { + if account == keyLocation.ServiceAccountName { + err = nil + accountKeyLocation = keyLocation + break + } + } + return +} + +//locationsToUpdate return a slice of structs that implement the keyWriter +// interface, based on the keyLocations supplied +func locationsToUpdate(keyLocation config.KeyLocations) (kws []location.KeyWriter) { + + // read locations + for _, circleCI := range keyLocation.CircleCI { + kws = append(kws, circleCI) + } + + if len(keyLocation.GitHub.OrgRepo) > 0 { + kws = append(kws, keyLocation.GitHub) + } + + for _, k8s := range keyLocation.K8s { + kws = append(kws, k8s) + } + + return +} + +//updateKeyLocation updates locations specified in keyLocations with the new key, e.g. GitHub, CircleCI an K8s +func updateKeyLocation(account string, keyLocations config.KeyLocations, keyID, key, keyProvider string, creds cred.Credentials) (err error) { + + // update locations + var updatedLocations []location.UpdatedLocation + + for _, locationToUpdate := range locationsToUpdate(keyLocations) { + + var updated location.UpdatedLocation + + if updated, err = locationToUpdate.Write(keyLocations.ServiceAccountName, keyID, key, creds); err != nil { + return + } + + updatedLocations = append(updatedLocations, updated) + } + + // all done + logger.Infow("Key locations updated", + "keyProvider", keyProvider, + "account", account, + "keyID", keyID, + "keyLocationUpdates", updatedLocations) + + return +} + +//validKey returns a bool reflecting whether the key is deemed to be valid, based +// on a number of provider-specific rules. E.g., if the provider is AWS, and +// not configured to include user keys, is the key a user key (and hence invalid)? +func validKey(key keys.Key, config config.Config) bool { + if key.Provider.Provider == "aws" { + return validAwsKey(key, config) + } + return true +} + +//filterKeys returns a keys.Key slice created by filtering the provided +// keys.Key slice based on specific rules for each provider +func filterKeys(keysToFilter []keys.Key, config config.Config, account string) (filteredKeys []keys.Key, err error) { + var selfKeys []keys.Key + for _, key := range keysToFilter { + //valid bool is used to filter out keys early, e.g. if config says don't + //include AWS user keys, and the current key happens to be a user key + if !validKey(key, config) { + continue + } + var eligible bool + if eligible, err = filterKey(account, config, key); err != nil { + return + } + if eligible { + //don't add the key to filteredKeys yet if it's deemed to be a 'self' key + // (i.e. the key belongs to the process performing this rotation) + if isSelf(config, key) { + logger.Infow("Key has been identified as a cloud-rotator key, so will be processed last", + "keyProvider", key.Provider, + "account", key.Account) + selfKeys = append(selfKeys, key) + } else { + filteredKeys = append(filteredKeys, key) + } + } + } + //now add the 'self' keys + filteredKeys = append(filteredKeys, selfKeys...) + return +} + +//isSelf returns true iff the key provided matches the 'self' defined in the +// config.cloudProvider. This means the key is the one being used in the +// rotation process, and should probably be rotated last. +func isSelf(config config.Config, key keys.Key) bool { + for _, cloudProvider := range config.CloudProviders { + if cloudProvider.Name == key.Provider.Provider && + cloudProvider.Project == key.Provider.GcpProject && + cloudProvider.Self == key.Account { + return true + } + } + return false +} + +//filterKey returns a bool indicating whether the key is eligible for 'use' +func filterKey(account string, config config.Config, key keys.Key) (eligible bool, err error) { + if len(account) > 0 { + //this means an overriding account has been supplied, i.e. from CLI + eligible = key.Account == account + } else if !config.RotationMode { + //rotation mode is false, so include the key so its age can be used + eligible = true + } else { + if eligible, err = isKeyEligible(config, key); err != nil { + return + } + } + return +} + +//isKeyEligible returns a bool indicating whether the key is eligible based on +// application config +func isKeyEligible(config config.Config, key keys.Key) (eligible bool, err error) { + filterAccounts := config.AccountFilter.Accounts + filterMode := config.AccountFilter.Mode + switch filterMode { + case "include": + eligible = keyDefinedInFiltering(filterAccounts, key) + case "exclude": + eligible = !keyDefinedInFiltering(filterAccounts, key) + default: + err = fmt.Errorf("Filter mode: %s is not supported", filterMode) + } + return +} + +//keyDefinedInFiltering returns a bool indicating whether the key matches +// a service account defined in the AccountFilter +func keyDefinedInFiltering(providerServiceAccounts []config.ProviderServiceAccounts, + key keys.Key) bool { + for _, psa := range providerServiceAccounts { + if psa.Provider.Name == key.Provider.Provider && + psa.Provider.Project == key.Provider.GcpProject { + for _, sa := range psa.ProviderAccounts { + if sa == key.Account { + return true + } + } + } + } + + return false +} + +//contains returns true if the string slice contains the specified string +func contains(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +//validAwsKey returns a bool that reflects whether the provided keys.Key is +// valid, based on aws-specific rules +func validAwsKey(key keys.Key, config config.Config) (valid bool) { + if config.IncludeAwsUserKeys { + valid = true + } else { + match, _ := regexp.MatchString("[a-zA-Z]\\.[a-zA-Z]", key.Name) + valid = !match + } + return +} + +//postMetric posts details of each keys.Key to a metrics api +func postMetric(keys []keys.Key, apiKey string, datadog config.Datadog) (err error) { + if len(apiKey) > 0 { + url := strings.Join([]string{datadogURL, apiKey}, "") + for _, key := range keys { + var jsonString = []byte( + `{ "series" :[{"metric":"` + datadog.MetricName + `",` + + `"points":[[` + + strconv.FormatInt(time.Now().Unix(), 10) + + `, ` + strconv.FormatFloat(key.Age, 'f', 2, 64) + + `]],` + + `"type":"count",` + + `"tags":[` + + `"team:` + datadog.MetricTeam + `",` + + `"environment:` + datadog.MetricEnv + `",` + + `"key:` + key.Name + `",` + + `"provider:` + key.Provider.Provider + `",` + + `"account:` + key.Account + + `"]}]}`) + var req *http.Request + if req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonString)); err != nil { + return + } + req.Header.Set("Content-type", "application/json") + client := &http.Client{} + var resp *http.Response + if resp, err = client.Do(req); err != nil { + return + } + defer resp.Body.Close() + if resp.StatusCode != 202 { + err = fmt.Errorf("non-202 status code (%d) returned by Datadog", resp.StatusCode) + } + } + } + return +} diff --git a/cmd/rotate_test.go b/pkg/rotate/rotatekeys_test.go similarity index 86% rename from cmd/rotate_test.go rename to pkg/rotate/rotatekeys_test.go index 8f1b0e09..2fe24ca1 100644 --- a/cmd/rotate_test.go +++ b/pkg/rotate/rotatekeys_test.go @@ -1,9 +1,10 @@ -package cmd +package rotate import ( "testing" keys "github.com/ovotech/cloud-key-client" + "github.com/ovotech/cloud-key-rotator/pkg/config" ) var includeFilterTests = []struct { @@ -24,15 +25,15 @@ var includeFilterTests = []struct { func TestFilterKeysInclude(t *testing.T) { for _, filterTest := range includeFilterTests { - psa := providerServiceAccounts{ - Provider: cloudProvider{Name: filterTest.provider, + psa := config.ProviderServiceAccounts{ + Provider: config.CloudProvider{Name: filterTest.provider, Project: filterTest.project}, ProviderAccounts: filterTest.saAccounts, } - psas := []providerServiceAccounts{psa} - includeFilter := filter{Mode: "include", Accounts: psas} - appConfig := config{RotationMode: true, AccountFilter: includeFilter} + psas := []config.ProviderServiceAccounts{psa} + includeFilter := config.Filter{Mode: "include", Accounts: psas} + appConfig := config.Config{RotationMode: true, AccountFilter: includeFilter} key := keys.Key{ Account: filterTest.keyAccount, @@ -62,7 +63,7 @@ var noFilterTests = []struct { func TestFilterKeysNoIncludeOrExclude(t *testing.T) { for _, noFilterTest := range noFilterTests { - appConfig := config{RotationMode: noFilterTest.rotationMode} + appConfig := config.Config{RotationMode: noFilterTest.rotationMode} key := keys.Key{ Account: noFilterTest.keyAccount, Provider: keys.Provider{ @@ -91,14 +92,14 @@ var excludeFilterTests = []struct { func TestFilterKeysExclude(t *testing.T) { for _, filterTest := range excludeFilterTests { - psa := providerServiceAccounts{ - Provider: cloudProvider{Name: filterTest.provider, + psa := config.ProviderServiceAccounts{ + Provider: config.CloudProvider{Name: filterTest.provider, Project: filterTest.project}, ProviderAccounts: filterTest.saAccounts, } - psas := []providerServiceAccounts{psa} - excludeFilter := filter{Mode: "exclude", Accounts: psas} - appConfig := config{RotationMode: true, AccountFilter: excludeFilter} + psas := []config.ProviderServiceAccounts{psa} + excludeFilter := config.Filter{Mode: "exclude", Accounts: psas} + appConfig := config.Config{RotationMode: true, AccountFilter: excludeFilter} key := keys.Key{ Account: filterTest.keyAccount, Provider: keys.Provider{ @@ -129,7 +130,7 @@ var validKeyTests = []struct { func TestValidKey(t *testing.T) { for _, validKeyTest := range validKeyTests { - appConfig := config{IncludeAwsUserKeys: validKeyTest.includeUserKeys} + appConfig := config.Config{IncludeAwsUserKeys: validKeyTest.includeUserKeys} key := keys.Key{Provider: keys.Provider{ Provider: validKeyTest.provider, GcpProject: validKeyTest.project}, Name: validKeyTest.keyName} expected := validKeyTest.valid @@ -153,7 +154,7 @@ var validAwsKeyTests = []struct { func TestValidAwsKey(t *testing.T) { for _, validAwsKeyTest := range validAwsKeyTests { - appConfig := config{IncludeAwsUserKeys: validAwsKeyTest.includeUserKeys} + appConfig := config.Config{IncludeAwsUserKeys: validAwsKeyTest.includeUserKeys} key := keys.Key{Name: validAwsKeyTest.keyName} expected := validAwsKeyTest.valid actual := validAwsKey(key, appConfig) @@ -198,12 +199,12 @@ var filterTests = []struct { func TestKeyDefinedInFiltering(t *testing.T) { for _, filterTest := range filterTests { - psa := providerServiceAccounts{ - Provider: cloudProvider{Name: filterTest.provider, + psa := config.ProviderServiceAccounts{ + Provider: config.CloudProvider{Name: filterTest.provider, Project: filterTest.project}, ProviderAccounts: filterTest.saAccounts, } - psas := []providerServiceAccounts{psa} + psas := []config.ProviderServiceAccounts{psa} key := keys.Key{ Account: filterTest.keyAccount, Provider: keys.Provider{