Skip to content

Commit 745798d

Browse files
authored
Merge pull request #6 from spiffe/strideynet/one-shot-credential-file-write
AWS Credentials File compatibility mode
2 parents 7e51c8a + 7b02989 commit 745798d

File tree

8 files changed

+690
-86
lines changed

8 files changed

+690
-86
lines changed

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,50 @@ $ aws-spiffe-workload-helper x509-credential-process \
8282
| session-duration | No | The duration, in seconds, of the resulting session. Optional. Can range from 15 minutes (900) to 12 hours (43200). | `3600` |
8383
| 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` |
8484

85+
#### `x509-credential-file`
8586

86-
### Configuring AWS SDKs and CLIs
87+
The `x509-credential-file` command starts a long-lived daemon which exchanges
88+
an X509 SVID for a short-lived set of AWS credentials using the AWS Roles
89+
Anywhere API. It writes the credentials to a specified file in the format
90+
supported by AWS SDKs and CLIs as a "credential file".
91+
92+
It repeats this exchange process when the AWS credentials are more than 50% of
93+
the way through their lifetime, ensuring that a fresh set of credentials are
94+
always available.
95+
96+
Whilst the `x509-credentials-process` flow should be preferred as it does not
97+
cause credentials to be written to the filesystem, the `x509-credentials-file`
98+
flow may be useful in scenarios where you need to provide credentials to legacy
99+
SDKs or CLIs that do not support the `credential_process` configuration.
100+
101+
The command fetches the X509-SVID from the SPIFFE Workload API. The location of
102+
the SPIFFE Workload API endpoint should be specified using the
103+
`SPIFFE_ENDPOINT_SOCKET` environment variable or the `--workload-api-addr` flag.
104+
105+
```sh
106+
$ aws-spiffe-workload-helper x509-credential-file \
107+
--trust-anchor-arn arn:aws:rolesanywhere:us-east-1:123456789012:trust-anchor/0000000-0000-0000-0000-000000000000 \
108+
--profile-arn arn:aws:rolesanywhere:us-east-1:123456789012:profile/0000000-0000-0000-0000-000000000000 \
109+
--role-arn arn:aws:iam::123456789012:role/example-role \
110+
--workload-api-addr unix:///opt/workload-api.sock \
111+
--aws-credentials-file /opt/my-aws-credentials-file
112+
```
113+
114+
###### Reference
115+
116+
| Flag | Required | Description | Example |
117+
|----------------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------|
118+
| role-arn | Yes | The ARN of the role to assume. Required. | `arn:aws:iam::123456789012:role/example-role` |
119+
| 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` |
120+
| 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` |
121+
| region | No | Overrides AWS region to use when exchanging the SVID for AWS credentials. Optional. | `us-east-1` |
122+
| session-duration | No | The duration, in seconds, of the resulting session. Optional. Can range from 15 minutes (900) to 12 hours (43200). | `3600` |
123+
| 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` |
124+
| aws-credentials-path | Yes | The path to the AWS credentials file to write. | `/opt/my-aws-credentials-file |
125+
| force | No | If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten. | |
126+
| replace | No | If set, the AWS credentials file will be replaced if it exists. This will remove any profiles not written by this tool. | |
127+
128+
## Configuring AWS SDKs and CLIs
87129

88130
To configure AWS SDKs and CLIs to use Roles Anywhere and SPIFFE for
89131
authentication, you will modify the AWS configuration file.

cmd/credential_file.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"time"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spiffe/aws-spiffe-workload-helper/internal"
11+
"github.com/spiffe/go-spiffe/v2/workloadapi"
12+
)
13+
14+
func newX509CredentialFileOneshotCmd() (*cobra.Command, error) {
15+
force := false
16+
replace := false
17+
awsCredentialsPath := ""
18+
sf := &sharedFlags{}
19+
cmd := &cobra.Command{
20+
Use: "x509-credential-file-oneshot",
21+
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.`,
22+
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.`,
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
return oneshotX509CredentialFile(
25+
cmd.Context(), force, replace, awsCredentialsPath, sf,
26+
)
27+
},
28+
}
29+
if err := sf.addFlags(cmd); err != nil {
30+
return nil, fmt.Errorf("adding shared flags: %w", err)
31+
}
32+
cmd.Flags().StringVar(&awsCredentialsPath, "aws-credentials-path", "", "The path to the AWS credentials file to write.")
33+
if err := cmd.MarkFlagRequired("aws-credentials-path"); err != nil {
34+
return nil, fmt.Errorf("marking aws-credentials-path flag as required: %w", err)
35+
}
36+
cmd.Flags().BoolVar(&force, "force", false, "If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten.")
37+
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.")
38+
39+
return cmd, nil
40+
}
41+
42+
func oneshotX509CredentialFile(
43+
ctx context.Context,
44+
force bool,
45+
replace bool,
46+
awsCredentialsPath string,
47+
sf *sharedFlags,
48+
) error {
49+
client, err := workloadapi.New(
50+
ctx,
51+
workloadapi.WithAddr(sf.workloadAPIAddr),
52+
)
53+
if err != nil {
54+
return fmt.Errorf("creating workload api client: %w", err)
55+
}
56+
defer func() {
57+
if err := client.Close(); err != nil {
58+
slog.Warn("Failed to close workload API client", "error", err)
59+
}
60+
}()
61+
62+
x509Ctx, err := client.FetchX509Context(ctx)
63+
if err != nil {
64+
return fmt.Errorf("fetching x509 context: %w", err)
65+
}
66+
svid := x509Ctx.DefaultSVID()
67+
slog.Info(
68+
"Fetched X509 SVID",
69+
"svid", svidValue(svid),
70+
)
71+
72+
credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
73+
if err != nil {
74+
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
75+
}
76+
77+
expiresAt, err := time.Parse(time.RFC3339, credentials.Expiration)
78+
if err != nil {
79+
return fmt.Errorf("parsing expiration time: %w", err)
80+
}
81+
82+
// Now we write this to disk in the format that the AWS CLI/SDK
83+
// expects for a credentials file.
84+
err = internal.UpsertAWSCredentialsFileProfile(
85+
slog.Default(),
86+
internal.AWSCredentialsFileConfig{
87+
Path: awsCredentialsPath,
88+
Force: force,
89+
ReplaceFile: replace,
90+
},
91+
internal.AWSCredentialsFileProfile{
92+
AWSAccessKeyID: credentials.AccessKeyId,
93+
AWSSecretAccessKey: credentials.SecretAccessKey,
94+
AWSSessionToken: credentials.SessionToken,
95+
},
96+
)
97+
if err != nil {
98+
return fmt.Errorf("writing credentials to file: %w", err)
99+
}
100+
slog.Info(
101+
"Wrote AWS credential to file",
102+
"path", awsCredentialsPath,
103+
"aws_expires_at", expiresAt,
104+
)
105+
return nil
106+
}
107+
108+
func newX509CredentialFileCmd() (*cobra.Command, error) {
109+
force := false
110+
replace := false
111+
awsCredentialsPath := ""
112+
sf := &sharedFlags{}
113+
cmd := &cobra.Command{
114+
Use: "x509-credential-file",
115+
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.`,
116+
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.`,
117+
RunE: func(cmd *cobra.Command, args []string) error {
118+
return daemonX509CredentialFile(
119+
cmd.Context(), force, replace, awsCredentialsPath, sf,
120+
)
121+
},
122+
}
123+
if err := sf.addFlags(cmd); err != nil {
124+
return nil, fmt.Errorf("adding shared flags: %w", err)
125+
}
126+
cmd.Flags().StringVar(&awsCredentialsPath, "aws-credentials-path", "", "The path to the AWS credentials file to write.")
127+
if err := cmd.MarkFlagRequired("aws-credentials-path"); err != nil {
128+
return nil, fmt.Errorf("marking aws-credentials-path flag as required: %w", err)
129+
}
130+
cmd.Flags().BoolVar(&force, "force", false, "If set, failures loading the existing AWS credentials file will be ignored and the contents overwritten.")
131+
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.")
132+
133+
return cmd, nil
134+
}
135+
136+
func daemonX509CredentialFile(
137+
ctx context.Context,
138+
force bool,
139+
replace bool,
140+
awsCredentialsPath string,
141+
sf *sharedFlags,
142+
) error {
143+
slog.Info("Starting AWS credential file daemon")
144+
client, err := workloadapi.New(
145+
ctx,
146+
workloadapi.WithAddr(sf.workloadAPIAddr),
147+
)
148+
if err != nil {
149+
return fmt.Errorf("creating workload api client: %w", err)
150+
}
151+
defer func() {
152+
if err := client.Close(); err != nil {
153+
slog.Warn("Failed to close workload API client", "error", err)
154+
}
155+
}()
156+
157+
slog.Debug("Fetching initial X509 SVID")
158+
x509Source, err := workloadapi.NewX509Source(ctx, workloadapi.WithClient(client))
159+
if err != nil {
160+
return fmt.Errorf("creating x509 source: %w", err)
161+
}
162+
defer func() {
163+
if err := x509Source.Close(); err != nil {
164+
slog.Warn("Failed to close x509 source", "error", err)
165+
}
166+
}()
167+
168+
svidUpdate := x509Source.Updated()
169+
svid, err := x509Source.GetX509SVID()
170+
if err != nil {
171+
return fmt.Errorf("fetching initial X509 SVID: %w", err)
172+
}
173+
slog.Info("Fetched initial X509 SVID", "svid", svidValue(svid))
174+
175+
for {
176+
slog.Debug(
177+
"Exchanging X509 SVID for AWS credentials",
178+
"svid", svidValue(svid),
179+
)
180+
credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
181+
if err != nil {
182+
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
183+
}
184+
slog.Info(
185+
"Successfully exchanged X509 SVID for AWS credentials",
186+
"svid", svidValue(svid),
187+
)
188+
189+
expiresAt, err := time.Parse(time.RFC3339, credentials.Expiration)
190+
if err != nil {
191+
return fmt.Errorf("parsing expiration time: %w", err)
192+
}
193+
194+
slog.Debug("Writing AWS credentials to file", "path", awsCredentialsPath)
195+
err = internal.UpsertAWSCredentialsFileProfile(
196+
slog.Default(),
197+
internal.AWSCredentialsFileConfig{
198+
Path: awsCredentialsPath,
199+
Force: force,
200+
ReplaceFile: replace,
201+
},
202+
internal.AWSCredentialsFileProfile{
203+
AWSAccessKeyID: credentials.AccessKeyId,
204+
AWSSecretAccessKey: credentials.SecretAccessKey,
205+
AWSSessionToken: credentials.SessionToken,
206+
},
207+
)
208+
if err != nil {
209+
return fmt.Errorf("writing credentials to file: %w", err)
210+
}
211+
slog.Info("Wrote AWS credentials to file", "path", awsCredentialsPath)
212+
213+
// Calculate next renewal time as 50% of the remaining time left on the
214+
// AWS credentials.
215+
// TODO(noah): This is a little crude, it may make more sense to just
216+
// renew on a fixed basis (e.g every minute?). We'll go with this
217+
// for now, and speak to consumers once it's in use to see if a
218+
// different mechanism may be more suitable.
219+
now := time.Now()
220+
awsTTL := expiresAt.Sub(now)
221+
renewIn := awsTTL / 2
222+
awsRenewAt := now.Add(renewIn)
223+
224+
slog.Info(
225+
"Sleeping until a new X509 SVID is received or the AWS credentials are close to expiry",
226+
"aws_expires_at", expiresAt,
227+
"aws_ttl", awsTTL,
228+
"aws_renews_at", awsRenewAt,
229+
"svid_expires_at", svid.Certificates[0].NotAfter,
230+
"svid_ttl", svid.Certificates[0].NotAfter.Sub(now),
231+
)
232+
233+
select {
234+
case <-time.After(time.Until(awsRenewAt)):
235+
slog.Info("Triggering renewal as AWS credentials are close to expiry")
236+
case <-svidUpdate:
237+
slog.Debug("Received potential X509 SVID update")
238+
newSVID, err := x509Source.GetX509SVID()
239+
if err != nil {
240+
return fmt.Errorf("fetching updated X509 SVID: %w", err)
241+
}
242+
slog.Info(
243+
"Received new X509 SVID from Workload API, will update AWS credentials",
244+
"svid", svidValue(svid),
245+
)
246+
svid = newSVID
247+
case <-ctx.Done():
248+
return nil
249+
}
250+
}
251+
}

cmd/credential_process.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spiffe/go-spiffe/v2/workloadapi"
11+
)
12+
13+
func newX509CredentialProcessCmd() (*cobra.Command, error) {
14+
sf := &sharedFlags{}
15+
cmd := &cobra.Command{
16+
Use: "x509-credential-process",
17+
Short: `Exchanges an X509 SVID for a short-lived set of AWS credentials using AWS Roles Anywhere. Compatible with the AWS credential process functionality.`,
18+
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.`,
19+
RunE: func(cmd *cobra.Command, args []string) error {
20+
ctx := cmd.Context()
21+
client, err := workloadapi.New(
22+
ctx,
23+
workloadapi.WithAddr(sf.workloadAPIAddr),
24+
)
25+
if err != nil {
26+
return fmt.Errorf("creating workload api client: %w", err)
27+
}
28+
defer func() {
29+
if err := client.Close(); err != nil {
30+
slog.Warn("Failed to close workload API client", "error", err)
31+
}
32+
}()
33+
34+
x509Ctx, err := client.FetchX509Context(ctx)
35+
if err != nil {
36+
return fmt.Errorf("fetching x509 context: %w", err)
37+
}
38+
// TODO(strideynet): Implement SVID selection mechanism, for now,
39+
// we'll just use the first returned SVID (a.k.a the default).
40+
svid := x509Ctx.DefaultSVID()
41+
slog.Debug("Fetched X509 SVID", "svid", svidValue(svid))
42+
43+
credentials, err := exchangeX509SVIDForAWSCredentials(sf, svid)
44+
if err != nil {
45+
return fmt.Errorf("exchanging X509 SVID for AWS credentials: %w", err)
46+
}
47+
48+
out, err := json.Marshal(credentials)
49+
if err != nil {
50+
return fmt.Errorf("marshalling credentials: %w", err)
51+
}
52+
_, err = os.Stdout.Write(out)
53+
if err != nil {
54+
return fmt.Errorf("writing credentials to stdout: %w", err)
55+
}
56+
return nil
57+
},
58+
}
59+
if err := sf.addFlags(cmd); err != nil {
60+
return nil, fmt.Errorf("adding shared flags: %w", err)
61+
}
62+
63+
return cmd, nil
64+
}

0 commit comments

Comments
 (0)