diff options
author | Mike Crute <mike@crute.us> | 2021-11-16 14:46:24 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2021-11-17 07:56:10 -0800 |
commit | cc58a3da7d647de8520e33dc4356672d2ed1a366 (patch) | |
tree | 1b232a0d51446eb6370cfb13932190d31ce053df /cloud | |
parent | a42d794a286154a3106551e6e483861af2a9ef16 (diff) | |
download | cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.tar.bz2 cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.tar.xz cloud-identity-broker-cc58a3da7d647de8520e33dc4356672d2ed1a366.zip |
Import of source code
Diffstat (limited to 'cloud')
-rw-r--r-- | cloud/aws/aws.go | 218 | ||||
-rw-r--r-- | cloud/aws/error.go | 11 |
2 files changed, 229 insertions, 0 deletions
diff --git a/cloud/aws/aws.go b/cloud/aws/aws.go new file mode 100644 index 0000000..180b2c4 --- /dev/null +++ b/cloud/aws/aws.go | |||
@@ -0,0 +1,218 @@ | |||
1 | package aws | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "fmt" | ||
6 | "io/ioutil" | ||
7 | "net/http" | ||
8 | "net/url" | ||
9 | "strconv" | ||
10 | |||
11 | "code.crute.us/mcrute/cloud-identity-broker/app/models" | ||
12 | |||
13 | "code.crute.us/mcrute/golib/vault" | ||
14 | "github.com/aws/aws-sdk-go/aws" | ||
15 | "github.com/aws/aws-sdk-go/aws/credentials" | ||
16 | "github.com/aws/aws-sdk-go/aws/session" | ||
17 | "github.com/aws/aws-sdk-go/service/ec2" | ||
18 | "github.com/aws/aws-sdk-go/service/sts" | ||
19 | ) | ||
20 | |||
21 | // defaultRegion is the region where AWS currently has may authoritative | ||
22 | // service like IAM and ancillary auth services. | ||
23 | var defaultRegion = aws.String("us-east-1") | ||
24 | |||
25 | type Region struct { | ||
26 | Name string | ||
27 | Enabled bool | ||
28 | } | ||
29 | |||
30 | type AWSClient interface { | ||
31 | AssumeRole(string, *string) (*sts.Credentials, error) | ||
32 | GetFederationURL(string, string) (string, error) | ||
33 | GetRegionList() ([]*Region, error) | ||
34 | } | ||
35 | |||
36 | // account models the account configuration stored in Vault for an AWS account | ||
37 | // with assumable roles that are stored within a kv JSON record. | ||
38 | type account struct { | ||
39 | AccessKeyId string | ||
40 | SecretAccessKey string | ||
41 | Roles map[string]struct { | ||
42 | ARN string | ||
43 | ExternalId string | ||
44 | } | ||
45 | } | ||
46 | |||
47 | type client struct { | ||
48 | AccessKeyId string | ||
49 | SecretAccessKey string | ||
50 | ARN string | ||
51 | ExternalId string | ||
52 | ConsoleSessionDurationSecs int64 | ||
53 | } | ||
54 | |||
55 | var _ AWSClient = (*client)(nil) | ||
56 | |||
57 | // NewAWSClientFromAccount returns a new AWSClient based on an account. | ||
58 | // | ||
59 | // An account is actually more accurately called an assumable role. Each | ||
60 | // account contains a vault material set path which is used to fetch all of the | ||
61 | // credentials for that AWS account and is filtered to one assumable role ARN | ||
62 | // which is used as the scope for this AWS client. Thus even if an account has | ||
63 | // multiple roles there must be one instance of the AWS client per account/role | ||
64 | // pair. | ||
65 | func NewAWSClientFromAccount(a *models.Account) (AWSClient, error) { | ||
66 | var ac account | ||
67 | if err := vault.GetVaultKeyStruct(a.VaultMaterial, &ac); err != nil { | ||
68 | return nil, err | ||
69 | } | ||
70 | |||
71 | r, ok := ac.Roles[a.ShortName] | ||
72 | if !ok { | ||
73 | return nil, fmt.Errorf("No roles for account %s in vault response", a.ShortName) | ||
74 | } | ||
75 | |||
76 | return &client{ | ||
77 | AccessKeyId: ac.AccessKeyId, | ||
78 | SecretAccessKey: ac.SecretAccessKey, | ||
79 | ARN: r.ARN, | ||
80 | ExternalId: r.ExternalId, | ||
81 | ConsoleSessionDurationSecs: a.ConsoleSessionDurationSecs(), | ||
82 | }, nil | ||
83 | } | ||
84 | |||
85 | // AssumeRole uses an IAM user credential with higher privilege to assume a | ||
86 | // role in an AWS account and region. It returns the STS credentials. | ||
87 | // | ||
88 | // Not all credentials work for all regions. For newer regions and opt-in | ||
89 | // regions AWS has been siloing assumed role credentials to that region so it's | ||
90 | // important to use the correct regional endpoint to fetch the credentials. | ||
91 | // | ||
92 | // Note that this is not simply a passthrough to Vault's AWS backend because | ||
93 | // the Vault backend works by assuming roles and when assuming roles with an | ||
94 | // assumed role AWS limits the chained role lifetime to 1 hour which doesn't | ||
95 | // work depending on how the upstream web application tied to this client | ||
96 | // works. This method instead uses a long-lived IAM user credential to assume a | ||
97 | // role, which has a limited lifetime which is typically greater than 1 hour. | ||
98 | func (a *client) AssumeRole(user string, region *string) (*sts.Credentials, error) { | ||
99 | if region != nil && *region == "global" { | ||
100 | region = nil | ||
101 | } | ||
102 | |||
103 | c := sts.New(session.New(&aws.Config{ | ||
104 | Region: region, | ||
105 | Credentials: credentials.NewStaticCredentials( | ||
106 | a.AccessKeyId, | ||
107 | a.SecretAccessKey, | ||
108 | "", | ||
109 | ), | ||
110 | })) | ||
111 | result, err := c.AssumeRole(&sts.AssumeRoleInput{ | ||
112 | ExternalId: aws.String(a.ExternalId), | ||
113 | RoleArn: aws.String(a.ARN), | ||
114 | RoleSessionName: aws.String(user), | ||
115 | DurationSeconds: aws.Int64(a.ConsoleSessionDurationSecs), | ||
116 | }) | ||
117 | if err != nil { | ||
118 | return nil, err | ||
119 | } | ||
120 | |||
121 | return result.Credentials, nil | ||
122 | } | ||
123 | |||
124 | // GetRegionList returns all of the EC2 regions that are enabled for an | ||
125 | // account. | ||
126 | // | ||
127 | // All regions should return the same answer to this query so just send it to | ||
128 | // the default region. | ||
129 | func (a *client) GetRegionList() ([]*Region, error) { | ||
130 | r, err := a.AssumeRole("region-list", defaultRegion) | ||
131 | if err != nil { | ||
132 | return nil, err | ||
133 | } | ||
134 | |||
135 | ec2c := ec2.New(session.New(&aws.Config{ | ||
136 | Region: defaultRegion, | ||
137 | Credentials: credentials.NewStaticCredentials( | ||
138 | *r.AccessKeyId, | ||
139 | *r.SecretAccessKey, | ||
140 | *r.SessionToken, | ||
141 | ), | ||
142 | })) | ||
143 | |||
144 | ro, err := ec2c.DescribeRegions(&ec2.DescribeRegionsInput{AllRegions: aws.Bool(true)}) | ||
145 | if err != nil { | ||
146 | return nil, err | ||
147 | } | ||
148 | |||
149 | out := []*Region{} | ||
150 | for _, r := range ro.Regions { | ||
151 | out = append(out, &Region{ | ||
152 | Name: *r.RegionName, | ||
153 | Enabled: (*r.OptInStatus != "not-opted-in"), | ||
154 | }) | ||
155 | } | ||
156 | |||
157 | return out, nil | ||
158 | } | ||
159 | |||
160 | // GetFederationURL assumes a role and returns a URL that can be used to login | ||
161 | // to the AWS console for that role. | ||
162 | // | ||
163 | // This URL can be used for any region but the endpoints to return the URL only | ||
164 | // work in us-east-1. | ||
165 | func (a *client) GetFederationURL(user string, issuerEndpoint string) (string, error) { | ||
166 | r, err := a.AssumeRole(user, defaultRegion) | ||
167 | if err != nil { | ||
168 | return "", err | ||
169 | } | ||
170 | |||
171 | sp, _ := json.Marshal(map[string]string{ | ||
172 | "sessionId": *r.AccessKeyId, | ||
173 | "sessionKey": *r.SecretAccessKey, | ||
174 | "sessionToken": *r.SessionToken, | ||
175 | }) | ||
176 | |||
177 | client := &http.Client{} | ||
178 | fedResp, err := client.Do(&http.Request{ | ||
179 | Method: http.MethodGet, | ||
180 | URL: &url.URL{ | ||
181 | Scheme: "https", | ||
182 | Host: "signin.aws.amazon.com", | ||
183 | Path: "/federation", | ||
184 | RawQuery: url.Values{ | ||
185 | "Action": []string{"getSigninToken"}, | ||
186 | "Session": []string{string(sp)}, | ||
187 | "SessionDuration": []string{strconv.FormatInt(a.ConsoleSessionDurationSecs, 10)}, | ||
188 | }.Encode(), | ||
189 | }, | ||
190 | }) | ||
191 | if err != nil { | ||
192 | return "", err | ||
193 | } | ||
194 | |||
195 | if fedResp.StatusCode != 200 { | ||
196 | return "", fmt.Errorf("federation endpoint returned HTTP %d", fedResp.StatusCode) | ||
197 | } | ||
198 | |||
199 | body, err := ioutil.ReadAll(fedResp.Body) | ||
200 | if err != nil { | ||
201 | return "", err | ||
202 | } | ||
203 | |||
204 | var fedData map[string]string | ||
205 | if err = json.Unmarshal(body, &fedData); err != nil { | ||
206 | return "", err | ||
207 | } | ||
208 | |||
209 | u, _ := url.Parse("https://signin.aws.amazon.com/federation") | ||
210 | u.RawQuery = url.Values{ | ||
211 | "Action": []string{"login"}, | ||
212 | "Issuer": []string{issuerEndpoint}, | ||
213 | "Destination": []string{"https://console.aws.amazon.com/"}, | ||
214 | "SigninToken": []string{fedData["SigninToken"]}, | ||
215 | }.Encode() | ||
216 | |||
217 | return u.String(), nil | ||
218 | } | ||
diff --git a/cloud/aws/error.go b/cloud/aws/error.go new file mode 100644 index 0000000..62fda48 --- /dev/null +++ b/cloud/aws/error.go | |||
@@ -0,0 +1,11 @@ | |||
1 | package aws | ||
2 | |||
3 | import ( | ||
4 | "strings" | ||
5 | ) | ||
6 | |||
7 | // IsRegionNotExist tries to determine if the error is caused by a region not | ||
8 | // existing, as would be the case in a user typo. | ||
9 | func IsRegionNotExist(err error) bool { | ||
10 | return strings.Contains(err.Error(), "no such host") | ||
11 | } | ||