Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AWS Credentials File compatibility mode #6

Merged
merged 10 commits into from
Dec 2, 2024
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,50 @@ $ aws-spiffe-workload-helper x509-credential-process \
| session-duration | No | The duration, in seconds, of the resulting session. Optional. Can range from 15 minutes (900) to 12 hours (43200). | `3600` |
| workload-api-addr | No | Overrides the address of the Workload API endpoint that will be use to fetch the X509 SVID. If unspecified, the value from the SPIFFE_ENDPOINT_SOCKET environment variable will be used. | `unix:///opt/my/path/workload.sock` |

#### `x509-credential-file`

### Configuring AWS SDKs and CLIs
The `x509-credential-file` command starts a long-lived daemon which exchanges
an X509 SVID for a short-lived set of AWS credentials using the AWS Roles
Anywhere API. It writes the credentials to a specified file in the format
supported by AWS SDKs and CLIs as a "credential file".

It repeats this exchange process when the AWS credentials are more than 50% of
the way through their lifetime, ensuring that a fresh set of credentials are
always available.

Whilst the `x509-credentials-process` flow should be preferred as it does not
cause credentials to be written to the filesystem, the `x509-credentials-file`
flow may be useful in scenarios where you need to provide credentials to legacy
SDKs or CLIs that do not support the `credential_process` configuration.

The command fetches the X509-SVID from the SPIFFE Workload API. The location of
the SPIFFE Workload API endpoint should be specified using the
`SPIFFE_ENDPOINT_SOCKET` environment variable or the `--workload-api-addr` flag.

```sh
$ aws-spiffe-workload-helper x509-credential-file \
--trust-anchor-arn arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000 \
--profile-arn arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-000000000000 \
--role-arn arn:aws:iam::123456789012:role/example-role \
--workload-api-addr unix:///opt/workload-api.sock \
--aws-credentials-file /opt/my-aws-credentials-file
```

###### Reference

| Flag | Required | Description | Example |
|----------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
| role-arn | Yes | The ARN of the role to assume. Required. | `arn:aws:iam::123456789012:role/example-role` |
| profile-arn | Yes | The ARN of the Roles Anywhere profile to use. Required. | `arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-00000000000` |
| trust-anchor-arn | Yes | The ARN of the Roles Anywhere trust anchor to use. Required. | `arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000` |
| region | No | Overrides AWS region to use when exchanging the SVID for AWS credentials. Optional. | `us-east-1` |
| session-duration | No | The duration, in seconds, of the resulting session. Optional. Can range from 15 minutes (900) to 12 hours (43200). | `3600` |
| workload-api-addr | No | Overrides the address of the Workload API endpoint that will be use to fetch the X509 SVID. If unspecified, the value from the SPIFFE_ENDPOINT_SOCKET environment variable will be used. | `unix:///opt/my/path/workload.sock` |
| aws-credentials-path | Yes | The path to the AWS credentials file to write. | `/opt/my-aws-credentials-file |
| force | No | If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten. | |
| replace | No | If set, the AWS credentials file will be replaced if it exists. This will remove any profiles not written by this tool. | |

## Configuring AWS SDKs and CLIs

To configure AWS SDKs and CLIs to use Roles Anywhere and SPIFFE for
authentication, you will modify the AWS configuration file.
Expand Down
251 changes: 251 additions & 0 deletions cmd/credential_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package main

import (
"context"
"fmt"
"log/slog"
"time"

"github.com/spf13/cobra"
"github.com/spiffe/aws-spiffe-workload-helper/internal"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func newX509CredentialFileOneshotCmd() (*cobra.Command, error) {
force := false
replace := false
awsCredentialsPath := ""
sf := &sharedFlags{}
cmd := &cobra.Command{
Use: "x509-credential-file-oneshot",
Short: `Exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Writes the credentials to a file in the 'credential file' format expected by the AWS CLI and SDKs.`,
Long: `Exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Writes the credentials to a file in the 'credential file' format expected by the AWS CLI and SDKs.`,
RunE: func(cmd *cobra.Command, args []string) error {
return oneshotX509CredentialFile(
cmd.Context(), force, replace, awsCredentialsPath, sf,
)
},
}
if err := sf.addFlags(cmd); err != nil {
return nil, fmt.Errorf("adding shared flags: %w", err)
}
cmd.Flags().StringVar(&awsCredentialsPath, "aws-credentials-path", "", "The path to the AWS credentials file to write.")
if err := cmd.MarkFlagRequired("aws-credentials-path"); err != nil {
return nil, fmt.Errorf("marking aws-credentials-path flag as required: %w", err)
}
cmd.Flags().BoolVar(&force, "force", false, "If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten.")
cmd.Flags().BoolVar(&replace, "replace", false, "If set, the AWS credentials file will be replaced if it exists. This will remove any profiles not written by this tool.")

return cmd, nil
}

func oneshotX509CredentialFile(
ctx context.Context,
force bool,
replace bool,
awsCredentialsPath string,
sf *sharedFlags,
) error {
client, err := workloadapi.New(
ctx,
workloadapi.WithAddr(sf.workloadAPIAddr),
)
if err != nil {
return fmt.Errorf("creating workload api client: %w", err)
}
defer func() {
if err := client.Close(); err != nil {
slog.Warn("Failed to close workload API client", "error", err)
}
}()

x509Ctx, err := client.FetchX509Context(ctx)
if err != nil {
return fmt.Errorf("fetching x509 context: %w", err)
}
svid := x509Ctx.DefaultSVID()
slog.Info(
"Fetched X509 SVID",
"svid", svidValue(svid),
)

credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
if err != nil {
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
}

expiresAt, err := time.Parse(time.RFC3339, credentials.Expiration)
if err != nil {
return fmt.Errorf("parsing expiration time: %w", err)
}

// Now we write this to disk in the format that the AWS CLI/SDK
// expects for a credentials file.
err = internal.UpsertAWSCredentialsFileProfile(
slog.Default(),
internal.AWSCredentialsFileConfig{
Path: awsCredentialsPath,
Force: force,
ReplaceFile: replace,
},
internal.AWSCredentialsFileProfile{
AWSAccessKeyID: credentials.AccessKeyId,
AWSSecretAccessKey: credentials.SecretAccessKey,
AWSSessionToken: credentials.SessionToken,
},
)
if err != nil {
return fmt.Errorf("writing credentials to file: %w", err)
}
slog.Info(
"Wrote AWS credential to file",
"path", awsCredentialsPath,
"aws_expires_at", expiresAt,
)
return nil
}

func newX509CredentialFileCmd() (*cobra.Command, error) {
force := false
replace := false
awsCredentialsPath := ""
sf := &sharedFlags{}
cmd := &cobra.Command{
Use: "x509-credential-file",
Short: `On a regular basis, this daemon exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Writes the credentials to a file in the 'credential file' format expected by the AWS CLI and SDKs.`,
Long: `On a regular basis, this daemon exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Writes the credentials to a file in the 'credential file' format expected by the AWS CLI and SDKs.`,
RunE: func(cmd *cobra.Command, args []string) error {
return daemonX509CredentialFile(
cmd.Context(), force, replace, awsCredentialsPath, sf,
)
},
}
if err := sf.addFlags(cmd); err != nil {
return nil, fmt.Errorf("adding shared flags: %w", err)
}
cmd.Flags().StringVar(&awsCredentialsPath, "aws-credentials-path", "", "The path to the AWS credentials file to write.")
if err := cmd.MarkFlagRequired("aws-credentials-path"); err != nil {
return nil, fmt.Errorf("marking aws-credentials-path flag as required: %w", err)
}
cmd.Flags().BoolVar(&force, "force", false, "If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten.")
cmd.Flags().BoolVar(&replace, "replace", false, "If set, the AWS credentials file will be replaced if it exists. This will remove any profiles not written by this tool.")

return cmd, nil
}

func daemonX509CredentialFile(
ctx context.Context,
force bool,
replace bool,
awsCredentialsPath string,
sf *sharedFlags,
) error {
slog.Info("Starting AWS credential file daemon")
client, err := workloadapi.New(
ctx,
workloadapi.WithAddr(sf.workloadAPIAddr),
)
if err != nil {
return fmt.Errorf("creating workload api client: %w", err)
}
defer func() {
if err := client.Close(); err != nil {
slog.Warn("Failed to close workload API client", "error", err)
}
}()

slog.Debug("Fetching initial X509 SVID")
x509Source, err := workloadapi.NewX509Source(ctx, workloadapi.WithClient(client))
if err != nil {
return fmt.Errorf("creating x509 source: %w", err)
}
defer func() {
if err := x509Source.Close(); err != nil {
slog.Warn("Failed to close x509 source", "error", err)
}
}()

svidUpdate := x509Source.Updated()
svid, err := x509Source.GetX509SVID()
if err != nil {
return fmt.Errorf("fetching initial X509 SVID: %w", err)
}
slog.Info("Fetched initial X509 SVID", "svid", svidValue(svid))

for {
slog.Debug(
"Exchanging X509 SVID for AWS credentials",
"svid", svidValue(svid),
)
credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
if err != nil {
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
}
slog.Info(
"Successfully exchanged X509 SVID for AWS credentials",
"svid", svidValue(svid),
)

expiresAt, err := time.Parse(time.RFC3339, credentials.Expiration)
if err != nil {
return fmt.Errorf("parsing expiration time: %w", err)
}

slog.Debug("Writing AWS credentials to file", "path", awsCredentialsPath)
err = internal.UpsertAWSCredentialsFileProfile(
slog.Default(),
internal.AWSCredentialsFileConfig{
Path: awsCredentialsPath,
Force: force,
ReplaceFile: replace,
},
internal.AWSCredentialsFileProfile{
AWSAccessKeyID: credentials.AccessKeyId,
AWSSecretAccessKey: credentials.SecretAccessKey,
AWSSessionToken: credentials.SessionToken,
},
)
if err != nil {
return fmt.Errorf("writing credentials to file: %w", err)
}
slog.Info("Wrote AWS credentials to file", "path", awsCredentialsPath)

// Calculate next renewal time as 50% of the remaining time left on the
// AWS credentials.
// TODO(noah): This is a little crude, it may make more sense to just
// renew on a fixed basis (e.g every minute?). We'll go with this
// for now, and speak to consumers once it's in use to see if a
// different mechanism may be more suitable.
now := time.Now()
awsTTL := expiresAt.Sub(now)
renewIn := awsTTL / 2
awsRenewAt := now.Add(renewIn)

slog.Info(
"Sleeping until a new X509 SVID is received or the AWS credentials are close to expiry",
"aws_expires_at", expiresAt,
"aws_ttl", awsTTL,
"aws_renews_at", awsRenewAt,
"svid_expires_at", svid.Certificates[0].NotAfter,
"svid_ttl", svid.Certificates[0].NotAfter.Sub(now),
)

select {
case <-time.After(time.Until(awsRenewAt)):
slog.Info("Triggering renewal as AWS credentials are close to expiry")
case <-svidUpdate:
slog.Debug("Received potential X509 SVID update")
newSVID, err := x509Source.GetX509SVID()
if err != nil {
return fmt.Errorf("fetching updated X509 SVID: %w", err)
}
slog.Info(
"Received new X509 SVID from Workload API, will update AWS credentials",
"svid", svidValue(svid),
)
svid = newSVID
case <-ctx.Done():
return nil
}
}
}
64 changes: 64 additions & 0 deletions cmd/credential_process.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"encoding/json"
"fmt"
"log/slog"
"os"

"github.com/spf13/cobra"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)

func newX509CredentialProcessCmd() (*cobra.Command, error) {
sf := &sharedFlags{}
cmd := &cobra.Command{
Use: "x509-credential-process",
Short: `Exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Compatible with the AWS credential process functionality.`,
Long: `Exchanges an X509 SVID for a short-lived set of AWS credentials using the AWS Roles Anywhere API. It returns the credentials to STDOUT, in the format expected by AWS SDKs and CLIs when invoking an external credential process.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := workloadapi.New(
ctx,
workloadapi.WithAddr(sf.workloadAPIAddr),
)
if err != nil {
return fmt.Errorf("creating workload api client: %w", err)
}
defer func() {
if err := client.Close(); err != nil {
slog.Warn("Failed to close workload API client", "error", err)
}
}()

x509Ctx, err := client.FetchX509Context(ctx)
if err != nil {
return fmt.Errorf("fetching x509 context: %w", err)
}
// TODO(strideynet): Implement SVID selection mechanism, for now,
// we'll just use the first returned SVID (a.k.a the default).
svid := x509Ctx.DefaultSVID()
slog.Debug("Fetched X509 SVID", "svid", svidValue(svid))

credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
if err != nil {
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
}

out, err := json.Marshal(credentials)
if err != nil {
return fmt.Errorf("marshalling credentials: %w", err)
}
_, err = os.Stdout.Write(out)
if err != nil {
return fmt.Errorf("writing credentials to stdout: %w", err)
}
return nil
},
}
if err := sf.addFlags(cmd); err != nil {
return nil, fmt.Errorf("adding shared flags: %w", err)
}

return cmd, nil
}
Loading