Skip to content

Commit cae90f8

Browse files
authored
Check AWS Admin access for cluster up and down commands (#878)
1 parent f18a97b commit cae90f8

File tree

9 files changed

+206
-17
lines changed

9 files changed

+206
-17
lines changed

cli/cmd/cluster.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,13 @@ var _downCmd = &cobra.Command{
237237
exit.Error(err)
238238
}
239239

240+
// Check AWS access
241+
awsClient, err := newAWSClient(*accessConfig.Region, awsCreds)
242+
if err != nil {
243+
exit.Error(err)
244+
}
245+
warnIfNotAdmin(awsClient)
246+
240247
prompt.YesOrExit(fmt.Sprintf("your cluster (%s in %s) will be spun down and all apis will be deleted, are you sure you want to continue?", *accessConfig.ClusterName, *accessConfig.Region), "", "")
241248

242249
out, exitCode, err := runManagerAccessCommand("/root/uninstall.sh", *accessConfig, awsCreds)

cli/cmd/lib_aws_creds.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ func newAWSClient(region string, awsCreds AWSCredentials) (*aws.Client, error) {
4646
return awsClient, nil
4747
}
4848

49+
func promptIfNotAdmin(awsClient *aws.Client) {
50+
accessKeyMsg := ""
51+
if accessKey := awsClient.AccessKeyID(); accessKey != nil {
52+
accessKeyMsg = fmt.Sprintf(" (with access key %s)", *accessKey)
53+
}
54+
55+
if !awsClient.IsAdmin() {
56+
prompt.YesOrExit(fmt.Sprintf("warning: your IAM user%s does not have administrator access. This will likely prevent Cortex from installing correctly, so it is recommended to attach the AdministratorAccess policy to your IAM user. If you'd like, you may provide separate credentials for your cluster to use after it's running (see https://cortex.dev/cluster-management/security for instructions).\nAre you sure you want to continue?", accessKeyMsg), "", "")
57+
}
58+
}
59+
60+
func warnIfNotAdmin(awsClient *aws.Client) {
61+
accessKeyMsg := ""
62+
if accessKey := awsClient.AccessKeyID(); accessKey != nil {
63+
accessKeyMsg = fmt.Sprintf(" (with access key %s)", *accessKey)
64+
}
65+
66+
if !awsClient.IsAdmin() {
67+
fmt.Println(fmt.Sprintf("warning: your IAM user%s does not have administrator access. This may prevent this command from executing correctly, so it is recommended to attach the AdministratorAccess policy to your IAM user.", accessKeyMsg), "", "")
68+
}
69+
}
70+
4971
var _awsCredentialsValidation = &cr.StructValidation{
5072
AllowExtraFields: true,
5173
StructFieldValidations: []*cr.StructFieldValidation{

cli/cmd/lib_cluster_config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ func getInstallClusterConfig(awsCreds AWSCredentials) (*clusterconfig.Config, er
140140
if err != nil {
141141
return nil, err
142142
}
143+
promptIfNotAdmin(awsClient)
143144

144145
err = clusterconfig.InstallPrompt(clusterConfig, awsClient)
145146
if err != nil {
@@ -182,6 +183,7 @@ func getClusterUpdateConfig(cachedClusterConfig clusterconfig.Config, awsCreds A
182183
if err != nil {
183184
return nil, err
184185
}
186+
promptIfNotAdmin(awsClient)
185187
} else {
186188
err := readUserClusterConfigFile(userClusterConfig)
187189
if err != nil {
@@ -194,6 +196,7 @@ func getClusterUpdateConfig(cachedClusterConfig clusterconfig.Config, awsCreds A
194196
if err != nil {
195197
return nil, err
196198
}
199+
promptIfNotAdmin(awsClient)
197200

198201
if userClusterConfig.Bucket != "" && userClusterConfig.Bucket != cachedClusterConfig.Bucket {
199202
return nil, clusterconfig.ErrorConfigCannotBeChangedOnUpdate(clusterconfig.BucketKey, cachedClusterConfig.Bucket)

docs/cluster-management/security.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@ _WARNING: you are on the master branch, please refer to the docs on the branch t
44

55
## IAM permissions
66

7-
If you are not using a sensitive AWS account and do not have a lot of experience with IAM configuration, attaching the existing policy `AdministratorAccess` to your IAM user will make getting started much easier.
7+
If you are not using a sensitive AWS account and do not have a lot of experience with IAM configuration, attaching the built-in `AdministratorAccess` policy to your IAM user will make getting started much easier. If you would like to limit IAM permissions, continue reading.
8+
9+
### Cluster spin-up
10+
11+
Spinning up Cortex on your AWS account requires more permissions that Cortex needs once it's running. You can specify different credentials for each purpose in two ways:
12+
13+
1. You can export the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` which will be used to create your cluster, and export `CORTEX_AWS_ACCESS_KEY_ID` and `CORTEX_AWS_SECRET_ACCESS_KEY` which will be used by the cluster.
14+
15+
2. If you are using a cluster configuration file (e.g. `cluster.yaml`), you can set the fields `aws_access_key_id` and `aws_secret_access_key` which will be used to create your cluster, and set `cortex_aws_access_key_id` and `cortex_aws_secret_access_key` which will be used by the cluster.
16+
17+
In either case, the credentials used when spinning up the cluster will not be used by the cluster itself, and can be safely revoked after the cluster is running. You may need credentials with similar access to run other `cortex cluster` commands, such as `cortex cluster update`, `cortex cluster info`, and `cortex cluster down`.
818

919
### Operator
1020

@@ -39,14 +49,12 @@ The operator requires read permissions for any S3 bucket containing exported mod
3949
}
4050
```
4151

52+
It is possible to further restrict access by limiting access to particular resources (e.g. allowing access to only the bucket containing your models and the cortex bucket).
53+
4254
### CLI
4355

4456
In order to connect to the operator via the CLI, you must provide valid AWS credentials for any user with access to the account. No special permissions are required. The CLI can be configured using the `cortex configure` command.
4557

46-
## API access
47-
48-
By default, your Cortex APIs will be accessible to all traffic. You can restrict access using AWS security groups. Specifically, you will need to edit the security group with the description: "Security group for Kubernetes ELB <ELB name> (istio-system/ingressgateway-apis)".
49-
5058
## HTTPS
5159

5260
All APIs are accessible via HTTPS. The certificate is autogenerated during installation using `localhost` as the Common Name (CN). Therefore, clients will need to skip certificate verification (e.g. `curl -k`) when using HTTPS.

pkg/lib/aws/clients.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/aws/aws-sdk-go/service/cloudwatch"
2222
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
2323
"github.com/aws/aws-sdk-go/service/ec2"
24+
"github.com/aws/aws-sdk-go/service/iam"
2425
"github.com/aws/aws-sdk-go/service/s3"
2526
"github.com/aws/aws-sdk-go/service/servicequotas"
2627
"github.com/aws/aws-sdk-go/service/sts"
@@ -34,6 +35,7 @@ type clients struct {
3435
cloudWatchLogs *cloudwatchlogs.CloudWatchLogs
3536
cloudWatchMetrics *cloudwatch.CloudWatch
3637
serviceQuotas *servicequotas.ServiceQuotas
38+
iam *iam.IAM
3739
}
3840

3941
func (c *Client) S3() *s3.S3 {
@@ -84,3 +86,10 @@ func (c *Client) ServiceQuotas() *servicequotas.ServiceQuotas {
8486
}
8587
return c.clients.serviceQuotas
8688
}
89+
90+
func (c *Client) IAM() *iam.IAM {
91+
if c.clients.iam == nil {
92+
c.clients.iam = iam.New(c.sess)
93+
}
94+
return c.clients.iam
95+
}

pkg/lib/aws/credentials.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ import (
2222
"github.com/aws/aws-sdk-go/aws/credentials"
2323
)
2424

25+
// access key ID may be unavailable depending on how the client was instantiated
26+
func (c *Client) AccessKeyID() *string {
27+
if c.sess.Config.Credentials == nil {
28+
return nil
29+
}
30+
31+
sessCreds, err := c.sess.Config.Credentials.Get()
32+
if err != nil {
33+
return nil
34+
}
35+
36+
if sessCreds.AccessKeyID == "" {
37+
return nil
38+
}
39+
40+
return &sessCreds.AccessKeyID
41+
}
42+
2543
func GetCredentialsFromCLIConfigFile() (string, string, error) {
2644
creds := credentials.NewSharedCredentials("", "")
2745
if creds == nil {

pkg/lib/aws/ec2.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func (c *Client) SpotInstancePrice(region string, instanceType string) (float64,
3333
StartTime: aws.Time(time.Now()),
3434
})
3535
if err != nil {
36-
return 0, errors.WithStack(err)
36+
return 0, errors.Wrap(err, "checking spot instance price")
3737
}
3838

3939
min := math.MaxFloat64

pkg/lib/aws/iam.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
Copyright 2020 Cortex Labs, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package aws
18+
19+
import (
20+
"github.com/aws/aws-sdk-go/service/iam"
21+
"github.com/cortexlabs/cortex/pkg/lib/errors"
22+
)
23+
24+
func (c *Client) GetUser() (iam.User, error) {
25+
getUserOutput, err := c.IAM().GetUser(nil)
26+
if err != nil {
27+
return iam.User{}, errors.WithStack(err)
28+
}
29+
return *getUserOutput.User, nil
30+
}
31+
32+
func (c *Client) GetGroupsForUser(userName string) ([]iam.Group, error) {
33+
input := &iam.ListGroupsForUserInput{
34+
UserName: &userName,
35+
}
36+
37+
var groups []iam.Group
38+
39+
err := c.IAM().ListGroupsForUserPages(input, func(page *iam.ListGroupsForUserOutput, lastPage bool) bool {
40+
for _, group := range page.Groups {
41+
groups = append(groups, *group)
42+
}
43+
return true
44+
})
45+
46+
if err != nil {
47+
return nil, errors.WithStack(err)
48+
}
49+
50+
return groups, nil
51+
}
52+
53+
func (c *Client) GetManagedPoliciesForUser(userName string) ([]iam.AttachedPolicy, error) {
54+
user, err := c.GetUser()
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
var policies []iam.AttachedPolicy
60+
61+
userManagedPolicies, err := c.IAM().ListAttachedUserPolicies(&iam.ListAttachedUserPoliciesInput{
62+
UserName: &userName,
63+
})
64+
if err != nil {
65+
return nil, errors.WithStack(err)
66+
}
67+
for _, policy := range userManagedPolicies.AttachedPolicies {
68+
policies = append(policies, *policy)
69+
}
70+
71+
groups, err := c.GetGroupsForUser(*user.UserName)
72+
if err != nil {
73+
return nil, err
74+
}
75+
76+
for _, group := range groups {
77+
groupManagedPolicies, err := c.IAM().ListAttachedGroupPolicies(&iam.ListAttachedGroupPoliciesInput{
78+
GroupName: group.GroupName,
79+
})
80+
if err != nil {
81+
return nil, errors.WithStack(err)
82+
}
83+
for _, policy := range groupManagedPolicies.AttachedPolicies {
84+
policies = append(policies, *policy)
85+
}
86+
}
87+
88+
return policies, nil
89+
}
90+
91+
func (c *Client) IsAdmin() bool {
92+
user, err := c.GetUser()
93+
if err != nil {
94+
return false
95+
}
96+
97+
policies, err := c.GetManagedPoliciesForUser(*user.UserName)
98+
if err != nil {
99+
return false
100+
}
101+
102+
for _, policy := range policies {
103+
if *policy.PolicyArn == "arn:aws:iam::aws:policy/AdministratorAccess" {
104+
return true
105+
}
106+
}
107+
108+
return false
109+
}

pkg/types/clusterconfig/clusterconfig.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -510,11 +510,18 @@ func (cc *Config) Validate(awsClient *aws.Client) error {
510510
}
511511

512512
instanceMetadata := aws.InstanceMetadatas[*cc.Region][instanceType]
513-
err := CheckSpotInstanceCompatibility(awsClient, chosenInstance, instanceMetadata, cc.SpotConfig.MaxPrice)
513+
err := CheckSpotInstanceCompatibility(chosenInstance, instanceMetadata)
514514
if err != nil {
515515
return errors.Wrap(err, InstanceDistributionKey)
516516
}
517517

518+
spotInstancePrice, awsErr := awsClient.SpotInstancePrice(instanceMetadata.Region, instanceMetadata.Type)
519+
if awsErr == nil {
520+
if err := CheckSpotInstancePriceCompatibility(chosenInstance, instanceMetadata, cc.SpotConfig.MaxPrice, spotInstancePrice); err != nil {
521+
return errors.Wrap(err, InstanceDistributionKey)
522+
}
523+
}
524+
518525
compatibleInstanceCount++
519526
}
520527

@@ -555,7 +562,7 @@ func CheckCortexSupport(instanceMetadata aws.InstanceMetadata) error {
555562
return nil
556563
}
557564

558-
func CheckSpotInstanceCompatibility(awsClient *aws.Client, target aws.InstanceMetadata, suggested aws.InstanceMetadata, maxPrice *float64) error {
565+
func CheckSpotInstanceCompatibility(target aws.InstanceMetadata, suggested aws.InstanceMetadata) error {
559566
if target.GPU > suggested.GPU {
560567
return ErrorIncompatibleSpotInstanceTypeGPU(target, suggested)
561568
}
@@ -568,17 +575,16 @@ func CheckSpotInstanceCompatibility(awsClient *aws.Client, target aws.InstanceMe
568575
return ErrorIncompatibleSpotInstanceTypeCPU(target, suggested)
569576
}
570577

571-
suggestedInstancePrice, err := awsClient.SpotInstancePrice(target.Region, suggested.Type)
572-
if err != nil {
573-
return err
574-
}
578+
return nil
579+
}
575580

576-
if (maxPrice == nil || *maxPrice == target.Price) && target.Price < suggestedInstancePrice {
577-
return ErrorSpotPriceGreaterThanTargetOnDemand(suggestedInstancePrice, target, suggested)
581+
func CheckSpotInstancePriceCompatibility(target aws.InstanceMetadata, suggested aws.InstanceMetadata, maxPrice *float64, spotInstancePrice float64) error {
582+
if (maxPrice == nil || *maxPrice == target.Price) && target.Price < spotInstancePrice {
583+
return ErrorSpotPriceGreaterThanTargetOnDemand(spotInstancePrice, target, suggested)
578584
}
579585

580-
if maxPrice != nil && *maxPrice < suggestedInstancePrice {
581-
return ErrorSpotPriceGreaterThanMaxPrice(suggestedInstancePrice, *maxPrice, suggested)
586+
if maxPrice != nil && *maxPrice < spotInstancePrice {
587+
return ErrorSpotPriceGreaterThanMaxPrice(spotInstancePrice, *maxPrice, suggested)
582588
}
583589
return nil
584590
}
@@ -604,10 +610,17 @@ func CompatibleSpotInstances(awsClient *aws.Client, targetInstance aws.InstanceM
604610
continue
605611
}
606612

607-
if err := CheckSpotInstanceCompatibility(awsClient, targetInstance, instanceMetadata, maxPrice); err != nil {
613+
if err := CheckSpotInstanceCompatibility(targetInstance, instanceMetadata); err != nil {
608614
continue
609615
}
610616

617+
spotInstancePrice, awsErr := awsClient.SpotInstancePrice(instanceMetadata.Region, instanceMetadata.Type)
618+
if awsErr == nil {
619+
if err := CheckSpotInstancePriceCompatibility(targetInstance, instanceMetadata, maxPrice, spotInstancePrice); err != nil {
620+
continue
621+
}
622+
}
623+
611624
compatibleInstances = append(compatibleInstances, instanceMetadata)
612625

613626
if len(compatibleInstances) == numInstances {

0 commit comments

Comments
 (0)