Skip to content

Commit

Permalink
Introduce short lived tokens (#20)
Browse files Browse the repository at this point in the history
This introduces the ability to generate short lived tokens from your
long lived credentials. Under the covers, it uses the same flow
(`cf-vault add ...`) but instead of populating the environment with
those credentials, it uses those credentials to call the Cloudflare API
and generate an API token scoped and with a TTL you define.

Here is an example without the short lived tokens

```toml
[profiles.long-lived-api-token]
auth_type = "api_token"
email = "[email protected]"
```

To use this, you can call `cf-vault exec long-lived-api-token` and the
environment will be populated like so:

```
CLOUDFLARE_VAULT_SESSION=long-lived-api-token
CLOUDFLARE_API_TOKEN=xxxxx
```

Similarly for an API key but with the addition of the
`CLOUDFLARE_EMAIL`.

Now, providing your API token (or global API key) has permissions to
create new API tokens, you can update your configuration to generate the
short lived credentials using the following configuration.

```toml
[profiles.my-api-key]
auth_type = "api_key"
email = "[email protected]"
session_duration = 10
permission_group_ids = [
  "c8fed203ed3043cba015a93ad1616f1f",
  "82e64a83756745bbbb1c9c2701bf816b"
]

  [[profiles.my-api-key.resources]]
  "com.cloudflare.api.account.zone.*" = "*"
```

Breaking down the new bits.

- `session_duration` is an integer of minutes you want to have the token
alive. Generally this should be low enough to only last a session
(15-60m) but does not have a maximum value.

- `permission_group_ids` an array of permission group IDs that map to
  the IDs from the API[1]. Only the IDs are needed here and the names
  are not required.

- `resources` is a map of objects (TOML calls them inline tables?) that
  have a key/value combination. This represents the scopes of which the
  *new* token will receive upon creation. For ease of creation, I
  recommend building the configuration you want in the UI and then as
  you hit send, watch the network calls and pull in the scopes to avoid
  fiddling with it manually.

Example of the single use token flow

- `cf-vault add my-long-lived-token` and follow the steps to add the new
  token (or API key).
- modify your `session_duration` to be greater than 0 but within an
  acceptable timeframe.
- `cf-vault exec my-long-lived-token -- <cmd here>` which will use the
  short lived credentials. You're able to confirm it is working by
  running `cf-vault exec ... -- env | grep -i cloudflare` and looking
  for the new CLOUDFLARE_SESSION_* variables.

The recommended way to use this is to generate a single API token that
has permissions to generate new tokens and store that in `cf-vault`.
While the global API key will work, it isn't scoped and can create
gun-foot scenarios due to having all the permissions.

Closes #1

[1]: https://api.cloudflare.com/#permission-groups-list-permission-groups
  • Loading branch information
jacobbednarz committed Nov 16, 2020
1 parent 517905a commit 9b7322f
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 38 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.envrc
build/*
11 changes: 7 additions & 4 deletions cmd/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ type tomlConfig struct {
}

type profile struct {
Email string `toml:"email"`
AuthType string `toml:"auth_type"`
Email string `toml:"email"`
AuthType string `toml:"auth_type"`
SessionDuration string `toml:"session_duration,omitempty"`
Resources map[string]interface{} `toml:"resources,omitempty"`
PermissionGroupIDs []string `toml:"permission_group_ids,omitempty"`
}

var addCmd = &cobra.Command{
Expand Down Expand Up @@ -105,7 +108,7 @@ var addCmd = &cobra.Command{
log.Fatalf("failed to open file at %s", home+defaultFullConfigPath)
}
defer configFile.Close()
if err := toml.NewEncoder(configFile).Indentation("").Encode(tomlConfigStruct); err != nil {
if err := toml.NewEncoder(configFile).Encode(tomlConfigStruct); err != nil {
log.Fatal(err)
}

Expand All @@ -116,7 +119,7 @@ var addCmd = &cobra.Command{
Data: []byte(authValue),
})

fmt.Println("Success! Credentials have been set and are now ready for use!")
fmt.Println("\nSuccess! Credentials have been set and are now ready for use!")
},
}

Expand Down
78 changes: 71 additions & 7 deletions cmd/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"os"
"strings"
"syscall"
"time"

"os/exec"

"github.com/99designs/keyring"
"github.com/cloudflare/cloudflare-go"
"github.com/mitchellh/go-homedir"
"github.com/pelletier/go-toml"
log "github.com/sirupsen/logrus"
Expand Down Expand Up @@ -67,13 +69,16 @@ var execCmd = &cobra.Command{
log.Fatal("unable to find home directory: ", err)
}

configFileContents, err := ioutil.ReadFile(home + defaultFullConfigPath)
configData, err := ioutil.ReadFile(home + defaultFullConfigPath)
if err != nil {
log.Fatal(err)
}

config := tomlConfig{}
toml.Unmarshal(configFileContents, &config)
err = toml.Unmarshal(configData, &config)
if err != nil {
log.Fatal(err)
}

if _, ok := config.Profiles[profileName]; !ok {
log.Fatalf("no profile matching %q found in the configuration file at %s", profileName, home+defaultFullConfigPath)
Expand All @@ -91,17 +96,76 @@ var execCmd = &cobra.Command{
log.Fatalf("failed to get item from keyring: %s", strings.ToLower(err.Error()))
}

cloudflareCreds := []string{
cloudflareEnvironment := []string{
fmt.Sprintf("CLOUDFLARE_VAULT_SESSION=%s", profileName),
fmt.Sprintf("CLOUDFLARE_EMAIL=%s", profile.Email),
fmt.Sprintf("CLOUDFLARE_%s=%s", strings.ToUpper(profile.AuthType), string(keychain.Data)),
}

// Not using short lived tokens so set the static API token or API key.
if profile.SessionDuration == "" {
if profile.AuthType == "api_key" {
cloudflareEnvironment = append(cloudflareEnvironment, fmt.Sprintf("CLOUDFLARE_EMAIL=%s", profile.Email))
}
cloudflareEnvironment = append(cloudflareEnvironment, fmt.Sprintf("CLOUDFLARE_%s=%s", strings.ToUpper(profile.AuthType), string(keychain.Data)))
} else {
var api *cloudflare.API
if profile.AuthType == "api_token" {
api, err = cloudflare.NewWithAPIToken(string(keychain.Data))
if err != nil {
log.Fatal(err)
}
} else {
api, err = cloudflare.New(string(keychain.Data), profile.Email)
if err != nil {
log.Fatal(err)
}
}

permissionGroups := []cloudflare.APITokenPermissionGroups{}
for _, permissionGroupID := range profile.PermissionGroupIDs {
permissionGroups = append(permissionGroups, cloudflare.APITokenPermissionGroups{ID: permissionGroupID})
}

parsedSessionDuration, err := time.ParseDuration(profile.SessionDuration)
if err != nil {
log.Fatal(err)
}
now, _ := time.Parse(time.RFC3339, time.Now().UTC().Format(time.RFC3339))
tokenExpiry := now.Add(time.Second * time.Duration(parsedSessionDuration.Seconds()))

token := cloudflare.APIToken{
Name: fmt.Sprintf("%s-%d", projectName, tokenExpiry.Unix()),
NotBefore: &now,
ExpiresOn: &tokenExpiry,
Policies: []cloudflare.APITokenPolicies{{
Effect: "allow",
Resources: profile.Resources,
PermissionGroups: permissionGroups,
}},
Condition: &cloudflare.APITokenCondition{
RequestIP: &cloudflare.APITokenRequestIPCondition{
In: []string{},
NotIn: []string{},
},
},
}

shortLivedToken, err := api.CreateAPIToken(token)
if err != nil {
log.Fatalf("failed to create API token: %s", err)
}

if shortLivedToken.Value != "" {
cloudflareEnvironment = append(cloudflareEnvironment, fmt.Sprintf("CLOUDFLARE_API_TOKEN=%s", shortLivedToken.Value))
}

cloudflareEnvironment = append(cloudflareEnvironment, fmt.Sprintf("CLOUDFLARE_SESSION_EXPIRY=%d", tokenExpiry.Unix()))
}

// Should a command not be provided, drop into a fresh shell with the
// credentials populated alongside the existing env.
if len(args) == 0 {
log.Debug("launching new shell with credentials populated")
envVars := append(syscall.Environ(), cloudflareCreds...)
envVars := append(syscall.Environ(), cloudflareEnvironment...)
syscall.Exec(os.Getenv("SHELL"), []string{os.Getenv("SHELL")}, envVars)
}

Expand All @@ -114,6 +178,6 @@ var execCmd = &cobra.Command{
log.Debugf("found executable %s", pathtoExec)
log.Debugf("executing command: %s", strings.Join(args, " "))

syscall.Exec(pathtoExec, args, cloudflareCreds)
syscall.Exec(pathtoExec, args, cloudflareEnvironment)
},
}
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ go 1.15

require (
github.com/99designs/keyring v1.1.6
github.com/cloudflare/cloudflare-go v0.13.5
github.com/mitchellh/go-homedir v1.1.0
github.com/pelletier/go-toml v1.8.2-0.20201011232708-5b4e7e5dcc56
github.com/sirupsen/logrus v1.2.0
github.com/spf13/cobra v1.1.1
github.com/stretchr/testify v1.4.0 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f // indirect
)

replace github.com/keybase/go-keychain => github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4
Loading

0 comments on commit 9b7322f

Please sign in to comment.