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

Cross account access feature #58

Merged
merged 11 commits into from
May 2, 2018
Merged

Conversation

sesh-kebab
Copy link
Contributor

@sesh-kebab sesh-kebab commented Apr 25, 2018

What does this pull request do?

Allows other aws accounts to access repositories in the ECR that the service is backed by. This allows for other aws account that has access, to pull images using the full ECR repo name rather than having the request forwarded through d.ims.io.

PR exposes a new account entity and allows you to list, add and delete aws account ids via the rest api. Additionally, when you add an account id, all the repositories in ECR have their policies updated to allow access from that account. The same applies for when you delete an account, all the repositories' policies are updated, to remove access for the deleted aws account id.

How should this be tested?

🔴 WARNING Do not use us-west-2 region when running the service locally. That region's ECR is backing our prod d.ims.io instance. So please make use of the --aws-region flag when running d.ims.io locally.

Test:

  • /accounts api endpoint GET, POST, DELETE
  • ensure POST and DELETE update the repository policy document. For example, adding account 123, should result in a policy document for all repositories with that account permission
  • note Sid and Principal properties below. They should match the account id added.
  • Adding 123 won't actually work as you will need a valid aws account id of sufficient length
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "123",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::123:root"
            },
            "Action": [
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:BatchCheckLayerAvailability"
            ]
        }
}

Other notes:
This work is in part to primarily address the private auth issue quintilesims/layer0#540.

Checklist

  • Unit tests
  • Terraform

closes: #57


response := make([]string, len(output.Items))
i := 0
for _, v := range output.Items {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first arg in range is the index!

for i, v := range output.Items {
    response[i] = aws.StringValue(v["AccountID"].S)
}

Copy link
Contributor Author

@sesh-kebab sesh-kebab Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hah yes it is - not sure what happened there 🤷‍♂️

t.Errorf("Column 'AccountID' was '%v', expected '%v'", v, want)
}

if input.Item["AccountID"].S == nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems redundant after the if v, want := aws.StringValue(input.Item["AccountID"].S) check above?

Copy link
Contributor Author

@sesh-kebab sesh-kebab Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ ✅

target := NewDynamoAccountManager("table", mockDynamoDB)

validatePutItemInput := func(input *dynamodb.PutItemInput) {
if v, want := aws.StringValue(input.TableName), "table"; v != want {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] you can use assert.Equal(t, expected, actual) for these guys

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

func (d *DynamoAccountManager) RevokeAccess(accountID string) error {
key := map[string]*dynamodb.AttributeValue{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this variable name should be item for consistency and because it seems appropriate with:

input.SetItem(item)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

mockDynamoDB.EXPECT().
PutItem(gomock.Any()).
Copy link
Contributor

@zpatrick zpatrick Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We typically reserve the mock.Do pattern when we only want to validate a subset of the input or if gomock can't handle checking equality correctly (typically, time.Time objects)

With that in mind, I think this would work just fine:

item := map[string]*dynamodb.AttributeValue{
    "AccountID": {
        S: aws.String("account-id"),
    },
}

input := &dynamodb.DeleteItemInput{}
input.SetTableName("table")
input.SetItem(item)

mockDynamoDB.EXPECT().
    PutItem(input).
    Return(&dynamodb.PutItemOutput{}, nil)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}

if getPolicyOutput.PolicyText != nil && aws.StringValue(getPolicyOutput.PolicyText) != "" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can make this more readable as well:

policy := models.PolicyDocument{}
if text := aws.StringValue(output.PolicyText); text != "" {
    if err := json.Unmarshal([]byte(text), &policy); err != nil {
        return nil, err
    }
}

return policy, nil

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return policyDoc, nil
}

func setRepositoryPolicy(e ecriface.ECRAPI, repositoryName, policyText string) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From above - Seems like just re-purposing this function a bit would allow us to remove most of the other helpers here. Looks like the callers only really ever need set since they grab all of the accounts prior to this call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agreed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a look at maximizing re-use but I'm not seeing a clear path to expand this function to re-purpose it. The problem is that there are 3 cases that require slightly different logic before setting the policy which seem better encapsulated in their own function:

  • add all accounts to a single new repo's policy
  • add single account to all repos' policies
  • remove single account from all repos' policies

@@ -84,6 +87,15 @@ func (r *RepositoryController) CreateRepository(c *fireball.Context) (fireball.R
return nil, err
}

accounts, err := r.account.Accounts()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this is here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We iterate over all the accounts to create a repository policy text every time a new repository is created. In hindsight though, this should probably be in addToRepositoryPolicy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I'm just not sure why this needs to happen during a repository creation though? As far as I can tell, no changes to the account table happen during this API call, so this seems like it wouldn't ever change the policy?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow. Are you suggesting that I cache a list of accounts so that I don't have to get the list of accounts from the dynamo db table?

Copy link
Contributor Author

@sesh-kebab sesh-kebab Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see what you mean - the policy is for each repository. So every time you create a new repo, the policy needs to be explicitly set for that repository (which by default is empty).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooh I see - I was thinking this was a single policy. That's why I was confused by the efficiency of using setPolicy above.

Statement []statement
}

type statement struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does aws-sdk-go have a struct for this? nothing wrong with this implementation; just more curious if anything.
Maybe if we're lucky aws-sdk-go has some nice helpers around this stuff?

policy := aws.PolicyDoc{}
policy.Statements.AddAction(...)

Copy link
Contributor Author

@sesh-kebab sesh-kebab Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick search in the aws-sdk-go repo on github but couldn't find such a struct. https://github.com/aws/aws-sdk-go/search?p=1&q=PolicyDocument&type=

p.Statement = temp
}

func (p *PolicyDocument) RenderPolicyText() string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm - the error swallowing is a little concerning here. Maybe we remove this helper function and allow the caller to properly handle errors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea I wasn't quite sure what to do here either. In theory though this error should never be triggered as we're not trying to serialize a non-serializable type.

response := make([]string, len(output.Items))
for i, v := range output.Items {
response[i] = aws.StringValue(v["AccountID"].S)
i++
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be removed since i is already updated as part of range

}

if err := request.Validate(); err != nil {
return nil, fireball.NewError(400, err, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the current implementation you are returning a non-json error in a json-formatted api. I would be pretty unsatisfied as a consumer of this API with this implementation so I think it does need to change.

If you wanted to do the least amount of work, I'd expect you to do something like:

if err := foo(); err != nil {
    return fireball.NewJSONError(400, err)
}

See the Layer0 controllers package to see how to unit test responses from fireball.

}

accounts = append(accounts, request.Account)
for _, r := range repositories {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficient in the sense that this (and RevokeAccess) turn into:

accounts = append(accounts, request.Account)
if err := setRepositoryPolicy(a.erc, r, accounts); err != nil {
    return nil, err
}

Times(2)

mockAccountManager.EXPECT().
GrantAccess(gomock.Any()).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still needs addressed

}

expected := []string{"1", "2", "3"}
assert.Equal(t, expected, result)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still needs addressed

}
}

if text := aws.StringValue(output.PolicyText); text != "" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's best practice in Go to declare variables as close to when they are used as possible - but I can say for sure that's the pattern we've been using in this codebase so I think for consistency policy should be declared right above the if text := ... line.

input.SetPolicyText(policyText)
input.SetRepositoryName(repositoryName)
if err := input.Validate(); err != nil {
return fireball.NewError(400, err, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's leave fireball errors up to the controllers/handlers.

}

statementCount := len(policyDoc.Statement)
policyDoc.RemoveAWSAccountPrincipal(accountID)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] another pattern I tend to see for this type of logic is to return a bool here to indicate if something actually changed.

@@ -84,6 +87,15 @@ func (r *RepositoryController) CreateRepository(c *fireball.Context) (fireball.R
return nil, err
}

accounts, err := r.account.Accounts()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooooh I see - I was thinking this was a single policy. That's why I was confused by the efficiency of using setPolicy above.

}

accounts = append(accounts, request.Account)
for _, r := range repositories {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, ok, I didn't realize there was one policy per repo; I was thinking there was only one policy total

@quintilesims quintilesims deleted a comment from sesh-kebab May 1, 2018
@quintilesims quintilesims deleted a comment from sesh-kebab May 1, 2018
@@ -29,6 +30,36 @@ func TestCreateRepository(t *testing.T) {
Do(validateCreateRepositoryInput).
Return(&ecr.CreateRepositoryOutput{}, nil)

validateGetRepositoryPolicyInput := func(input *ecr.GetRepositoryPolicyInput) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] ditto - I'm not sure we why aren't using this pattern:

gomock.EXPECT().
    GetRepositoryPolicy(input).
    Return(...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure? I also thought it was odd.

@@ -16,7 +16,8 @@ func TestCreateRepository(t *testing.T) {
defer ctrl.Finish()

mockECR := mock.NewMockECRAPI(ctrl)
controller := NewRepositoryController(mockECR)
mockAccountManager := mock.NewMockAccountManager(ctrl)
controller := NewRepositoryController(mockECR, mockAccountManager)

validateCreateRepositoryInput := func(input *ecr.CreateRepositoryInput) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] While we're here - can we change this to be consistent with the other gomock input patterns?

mockECR.EXPECT().
    CreateRepository(input).
    Return(...)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return result
}

func (p *PolicyDocument) RenderPolicyText() string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know it's not a huge deal as-is, but I'm more worried about some dev seeing this in the future, thinking this is an acceptable pattern, and then using it somewhere where it actually does become an issue.
I think it would be wise to either:

  • Change the return to string, error
  • Remove this function and leave marshalling up to the caller

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, just in case you didn't already know - you can customize how objects get marshaled with json.Marshal by implementing the Marshaler interface.

I don't think this is a necessary or even a very useful option in this context, but figured it was a nice thing worth knowing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sesh-kebab sesh-kebab merged commit 757a913 into develop May 2, 2018
@zpatrick zpatrick deleted the cross-account-access-feature branch May 3, 2018 22:52
zpatrick added a commit that referenced this pull request May 30, 2018
* CreateRepository: Return error if 'name' field contains '/'

* Spec out 'image' endpoints; ListImages

* Add DeleteImage

* Add GetImage

* '/image' endpoint tests

* remove swagger host from configuration

* change image model. return list of strings for get repo images

* 429 and throttling work

* add zpatrick's 'go-bytesize' to vendor dir

* improve backoff logic

* test Auth0Manager retries on 429s

* code review fixes

* add auth0 request logging

* Empty user pass fix (#54)

* do not attempt to authenticate with an empty user or pass
* added test case for authenticating with empty user and pass

* Cross account access feature (#58)

* Private auth support
- new accounts entity. can add/remove aws account ids.
- creating new repos will now add permissions for all accounts.

* Added code to update policies for all repositories:
 - when accounts are added
 - when accounts are deleted

* add migrator tool

* fix repo uri bug

* Update migrate.py

* Update migrate.py

* Update router.go

* Update variables.tf

* Update main.tf

* make debug mode configurable

* add if else logic to template

* use dep for vendoring

* add log decorator

* remove unused registry endpoint config value

* add full debug authentication output

* change order of decorator

* update cache dep

* fix swagger doc delete tag

* remove 401 response code from auth unit test

* remove comment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Enable cross account access
2 participants