├── fixtures ├── 0.0.1 ├── 0.0.2 ├── 0.0.2.buildtag └── resources │ ├── iam-policy-after-fmt.yaml │ └── iam-policy-before-fmt.yaml ├── .gitignore ├── iamy ├── testdata │ ├── awsdiff │ │ ├── max-session-duration-remote1 │ │ │ └── myalias-123 │ │ │ │ └── .gitkeep │ │ ├── testcase1-remote │ │ │ └── myalias-123 │ │ │ │ └── iam │ │ │ │ ├── policy │ │ │ │ └── test.yaml │ │ │ │ └── role │ │ │ │ └── testrole.yaml │ │ ├── testcase1-local │ │ │ └── myalias-123 │ │ │ │ └── iam │ │ │ │ └── role │ │ │ │ └── testrole.yaml │ │ ├── max-session-duration-remote2 │ │ │ └── myalias-123 │ │ │ │ └── iam │ │ │ │ └── role │ │ │ │ └── testrole.yaml │ │ └── max-session-duration-local │ │ │ └── myalias-123 │ │ │ └── iam │ │ │ └── role │ │ │ └── testrole.yaml │ └── myalias-123 │ │ ├── iam │ │ ├── group │ │ │ └── DevOps.yaml │ │ ├── user │ │ │ └── foo │ │ │ │ └── billy.blogs.yaml │ │ ├── role │ │ │ └── ecsInstanceRole.yaml │ │ └── policy │ │ │ └── TestPolicyAccess.yaml │ │ └── s3 │ │ └── my-bucket.yaml ├── map.go ├── awssession.go ├── models_test.go ├── slice.go ├── yaml_test.go ├── cfn_test.go ├── resourcegroupstagging.go ├── awsdiff_test.go ├── policy_test.go ├── iam.go ├── awsaccountid.go ├── policy.go ├── s3.go ├── cfn.go ├── yaml.go ├── models.go ├── aws_test.go ├── aws.go └── awsdiff.go ├── .github └── workflows │ └── go.yml ├── fmt.go ├── go.mod ├── pull.go ├── LICENSE ├── Makefile ├── version.go ├── version_test.go ├── fmt_test.go ├── push.go ├── README.md ├── go.sum └── iamy.go /fixtures/0.0.1: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | -------------------------------------------------------------------------------- /fixtures/0.0.2: -------------------------------------------------------------------------------- 1 | 0.0.2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | .idea 3 | *.iml 4 | -------------------------------------------------------------------------------- /fixtures/0.0.2.buildtag: -------------------------------------------------------------------------------- 1 | 0.0.2+buildtag 2 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/max-session-duration-remote1/myalias-123/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/testcase1-remote/myalias-123/iam/policy/test.yaml: -------------------------------------------------------------------------------- 1 | Policy: 2 | Statement: 3 | - Action: 4 | - ec2:Describe* 5 | Effect: Allow 6 | Resource: '*' 7 | Version: 2012-10-17 8 | -------------------------------------------------------------------------------- /iamy/map.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | func mapStringSetDifference(aa, bb map[string]string) map[string]string { 4 | rr := make(map[string]string) 5 | for k, v := range aa { 6 | if bb[k] != v { 7 | rr[k] = v 8 | } 9 | } 10 | return rr 11 | } 12 | -------------------------------------------------------------------------------- /iamy/testdata/myalias-123/iam/group/DevOps.yaml: -------------------------------------------------------------------------------- 1 | Roles: [] 2 | InlinePolicies: 3 | - Name: AdministratorAccess-DevOps-201106140931 4 | Policy: 5 | Statement: 6 | - Action: '*' 7 | Effect: Allow 8 | Resource: '*' 9 | Policies: [] 10 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/testcase1-local/myalias-123/iam/role/testrole.yaml: -------------------------------------------------------------------------------- 1 | AssumeRolePolicyDocument: 2 | Statement: 3 | - Action: sts:AssumeRole 4 | Effect: Allow 5 | Principal: 6 | AWS: arn:aws:iam::123:root 7 | Sid: "" 8 | Version: 2012-10-17 9 | -------------------------------------------------------------------------------- /fixtures/resources/iam-policy-after-fmt.yaml: -------------------------------------------------------------------------------- 1 | Policy: 2 | Statement: 3 | - Action: 4 | - ec2:StopInstances 5 | - ec2:TerminateInstances 6 | - iam:* 7 | Effect: Allow 8 | Resource: '*' 9 | Sid: ExampleStatement 10 | Version: "2012-10-17" 11 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/max-session-duration-remote2/myalias-123/iam/role/testrole.yaml: -------------------------------------------------------------------------------- 1 | AssumeRolePolicyDocument: 2 | Statement: 3 | - Action: sts:AssumeRole 4 | Effect: Allow 5 | Principal: 6 | AWS: arn:aws:iam::123:root 7 | Sid: "" 8 | Version: 2012-10-17 9 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/testcase1-remote/myalias-123/iam/role/testrole.yaml: -------------------------------------------------------------------------------- 1 | AssumeRolePolicyDocument: 2 | Statement: 3 | - Action: sts:AssumeRole 4 | Effect: Allow 5 | Principal: 6 | AWS: arn:aws:iam::123:root 7 | Sid: "" 8 | Version: 2012-10-17 9 | Policies: 10 | - test 11 | -------------------------------------------------------------------------------- /iamy/testdata/awsdiff/max-session-duration-local/myalias-123/iam/role/testrole.yaml: -------------------------------------------------------------------------------- 1 | AssumeRolePolicyDocument: 2 | Statement: 3 | - Action: sts:AssumeRole 4 | Effect: Allow 5 | Principal: 6 | AWS: arn:aws:iam::123:root 7 | Sid: "" 8 | Version: 2012-10-17 9 | MaxSessionDuration: 7200 10 | -------------------------------------------------------------------------------- /iamy/testdata/myalias-123/iam/user/foo/billy.blogs.yaml: -------------------------------------------------------------------------------- 1 | Groups: 2 | - DevOps 3 | InlinePolicies: 4 | - Name: AdministratorAccess-billy.blogs 5 | Policy: 6 | Statement: 7 | - Action: '*' 8 | Effect: Allow 9 | Resource: '*' 10 | Version: 2012-10-17 11 | Policies: 12 | - AmazonEC2ReadOnlyAccess 13 | -------------------------------------------------------------------------------- /fixtures/resources/iam-policy-before-fmt.yaml: -------------------------------------------------------------------------------- 1 | # comments must be stripped 2 | Policy: 3 | Statement: 4 | - Sid: ExampleStatement 5 | Action: 6 | - ec2:TerminateInstances 7 | - ec2:StopInstances 8 | - 'iam:*' 9 | Effect: Allow 10 | Resource: 11 | - '*' 12 | Version: 2012-10-17 13 | -------------------------------------------------------------------------------- /iamy/awssession.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/aws/aws-sdk-go/aws/session" 7 | ) 8 | 9 | var sess *session.Session 10 | 11 | func awsSession() *session.Session { 12 | if sess == nil { 13 | var err error 14 | sess, err = session.NewSessionWithOptions(session.Options{ 15 | SharedConfigState: session.SharedConfigEnable, 16 | }) 17 | 18 | if err != nil { 19 | log.Fatal("awsSession: couldn't create an AWS session", err) 20 | } 21 | } 22 | 23 | return sess 24 | } 25 | -------------------------------------------------------------------------------- /iamy/testdata/myalias-123/s3/my-bucket.yaml: -------------------------------------------------------------------------------- 1 | Policy: 2 | Statement: 3 | - Action: s3:GetObject 4 | Effect: Allow 5 | Principal: '*' 6 | Resource: arn:aws:s3:::my-bucket/* 7 | Sid: AllowGet 8 | - Action: 9 | - s3:ListBucket 10 | - s3:GetBucketLocation 11 | Effect: Allow 12 | Principal: 13 | AWS: 14 | - arn:aws:iam::111111111111:root 15 | - arn:aws:iam::222222222222:root 16 | Resource: arn:aws:s3:::my-bucket-2 17 | Sid: AllowList 18 | Version: 2012-10-17 19 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.16 20 | 21 | - name: Check tidy 22 | run: go mod tidy && git diff --quiet go.mod go.sum 23 | 24 | - name: Build 25 | run: make clean release 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /iamy/testdata/myalias-123/iam/role/ecsInstanceRole.yaml: -------------------------------------------------------------------------------- 1 | AssumeRolePolicyDocument: 2 | Statement: 3 | - Action: sts:AssumeRole 4 | Effect: Allow 5 | Principal: 6 | Service: ec2.amazonaws.com 7 | Sid: "" 8 | Version: 2008-10-17 9 | InlinePolicies: 10 | - Name: oneClick_ecsInstanceRole_1432518085657 11 | Policy: 12 | Statement: 13 | - Action: 14 | - ecs:CreateCluster 15 | - ecs:DeregisterContainerInstance 16 | - ecs:DiscoverPollEndpoint 17 | - ecs:Poll 18 | - ecs:RegisterContainerInstance 19 | - ecs:Submit* 20 | Effect: Allow 21 | Resource: 22 | - '*' 23 | Version: 2012-10-17 24 | -------------------------------------------------------------------------------- /fmt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/envato/iamy/iamy" 4 | 5 | type FormatCommandInput struct { 6 | Dir string 7 | CanDelete bool 8 | } 9 | 10 | func FormatCommand(ui Ui, input FormatCommandInput) { 11 | if *dryRun { 12 | ui.Fatal("Dry-run mode not supported for fmt") 13 | } 14 | 15 | yaml := iamy.YamlLoadDumper{ 16 | Dir: input.Dir, 17 | } 18 | 19 | allDataFromYaml, err := yaml.Load() 20 | if err != nil { 21 | ui.Fatal(err) 22 | return 23 | } 24 | 25 | for _, account := range allDataFromYaml { 26 | ui.Printf("Formatting %s (%s)", account.Account.Alias, account.Account.Id) 27 | 28 | err = yaml.Dump(&account, input.CanDelete) 29 | if err != nil { 30 | ui.Error.Fatal(err) 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/envato/iamy 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect 7 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect 8 | github.com/aws/aws-sdk-go v1.42.39 9 | github.com/blang/semver/v4 v4.0.0 10 | github.com/davecgh/go-spew v1.1.1 // indirect 11 | github.com/fatih/color v1.7.0 12 | github.com/ghodss/yaml v1.0.0 13 | github.com/kr/pretty v0.1.0 // indirect 14 | github.com/mattn/go-colorable v0.0.9 // indirect 15 | github.com/mattn/go-isatty v0.0.4 // indirect 16 | github.com/pkg/errors v0.9.1 17 | github.com/stretchr/testify v1.3.0 18 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 19 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /iamy/models_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import "testing" 4 | 5 | func TestNewAccountFromString(t *testing.T) { 6 | 7 | a := NewAccountFromString("test-it-123456789012") 8 | expected := "123456789012" 9 | if a.Id != expected { 10 | t.Errorf("Expected %s, got %s", expected, a.Id) 11 | } 12 | expected = "test-it" 13 | if a.Alias != expected { 14 | t.Errorf("Expected %s, got %s", expected, a.Alias) 15 | } 16 | } 17 | 18 | func TestNewAccountFromStringWithNoAlias(t *testing.T) { 19 | 20 | a := NewAccountFromString("123456789012") 21 | expected := "123456789012" 22 | if a.Id != expected { 23 | t.Errorf("Expected %s, got %s", expected, a.Id) 24 | } 25 | expected = "" 26 | if a.Alias != expected { 27 | t.Errorf("Expected %s, got %s", expected, a.Alias) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /iamy/slice.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import "reflect" 4 | 5 | // inlinePolicySetDifference is the set of elements in aa but not in bb 6 | func inlinePolicySetDifference(aa, bb []InlinePolicy) []InlinePolicy { 7 | rr := []InlinePolicy{} 8 | 9 | LoopInlinePolicies: 10 | for _, a := range aa { 11 | for _, b := range bb { 12 | if reflect.DeepEqual(a, b) { 13 | continue LoopInlinePolicies 14 | } 15 | } 16 | 17 | rr = append(rr, a) 18 | } 19 | 20 | return rr 21 | } 22 | 23 | // stringSetDifference is the set of elements in aa but not in bb 24 | func stringSetDifference(aa, bb []string) []string { 25 | rr := []string{} 26 | 27 | LoopStrings: 28 | for _, a := range aa { 29 | for _, b := range bb { 30 | if reflect.DeepEqual(a, b) { 31 | continue LoopStrings 32 | } 33 | } 34 | 35 | rr = append(rr, a) 36 | } 37 | 38 | return rr 39 | } 40 | -------------------------------------------------------------------------------- /pull.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/envato/iamy/iamy" 7 | ) 8 | 9 | type PullCommandInput struct { 10 | Dir string 11 | CanDelete bool 12 | HeuristicCfnMatching bool 13 | SkipTagged []string 14 | IncludeTagged []string 15 | SkipPathPrefixes []string 16 | } 17 | 18 | func PullCommand(ui Ui, input PullCommandInput) { 19 | aws := iamy.AwsFetcher{ 20 | Debug: ui.Debug, 21 | HeuristicCfnMatching: input.HeuristicCfnMatching, 22 | SkipTagged: input.SkipTagged, 23 | IncludeTagged: input.IncludeTagged, 24 | SkipPathPrefixes: input.SkipPathPrefixes, 25 | } 26 | data, err := aws.Fetch() 27 | if err != nil { 28 | ui.Error.Fatal(fmt.Printf("%s", err)) 29 | } 30 | 31 | yaml := iamy.YamlLoadDumper{ 32 | Dir: input.Dir, 33 | } 34 | err = yaml.Dump(data, input.CanDelete) 35 | if err != nil { 36 | ui.Error.Fatal(err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /iamy/testdata/myalias-123/iam/policy/TestPolicyAccess.yaml: -------------------------------------------------------------------------------- 1 | Name: TestPolicyAccess 2 | IsAttachable: true 3 | Version: v2 4 | Policy: 5 | Statement: 6 | - Action: 7 | - '*' 8 | Effect: Allow 9 | Resource: 10 | - '*' 11 | Sid: AllowAll 12 | - Action: 13 | - ec2:StopInstances 14 | - ec2:TerminateInstances 15 | - iam:* 16 | Condition: 17 | Bool: 18 | aws:MultiFactorAuthPresent: false 19 | Effect: Deny 20 | Resource: '*' 21 | Sid: DenyStopAndTerminateWhenMFAIsFalse 22 | - Action: 23 | - ec2:StopInstances 24 | - ec2:TerminateInstances 25 | - iam:* 26 | Condition: 27 | "Null": 28 | aws:MultiFactorAuthPresent: true 29 | Effect: Deny 30 | Resource: '*' 31 | Sid: DenyStopAndTerminateWhenMFAIsNotPresent 32 | - Action: 33 | - s3:* 34 | Effect: Deny 35 | Resource: 36 | - arn:aws:s3:::mybucket/* 37 | Sid: DenyDeleteOnCriticalBuckets 38 | Version: 2012-10-17 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 99designs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /iamy/yaml_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path/filepath" 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func readDir(path string) map[string][]byte { 13 | files := map[string][]byte{} 14 | filepath.Walk(path, func(p string, info os.FileInfo, err error) error { 15 | if err != nil { 16 | panic(err) 17 | } 18 | if !info.IsDir() { 19 | files[info.Name()], err = ioutil.ReadFile(p) 20 | if err != nil { 21 | panic(err) 22 | } 23 | } 24 | 25 | return nil 26 | }) 27 | 28 | return files 29 | } 30 | 31 | func newTmpDir() string { 32 | testdir, err := ioutil.TempDir("", "loaddumpertest") 33 | if err != nil { 34 | panic(err.Error()) 35 | } 36 | fmt.Println("Creating tmp dir", testdir) 37 | return testdir 38 | } 39 | 40 | func TestRoundTrip(t *testing.T) { 41 | d, err := os.Getwd() 42 | if err != nil { 43 | t.Fatal(err.Error()) 44 | } 45 | 46 | y := YamlLoadDumper{} 47 | y.Dir = filepath.Join(d, "testdata") 48 | accountData, err := y.Load() 49 | if err != nil { 50 | t.Fatal(err.Error()) 51 | } 52 | 53 | testdir := newTmpDir() 54 | y.Dir = testdir 55 | err = y.Dump(&accountData[0], false) 56 | if err != nil { 57 | t.Fatal(err.Error()) 58 | } 59 | 60 | yamlDirData := readDir(y.Dir) 61 | testdirData := readDir(testdir) 62 | eq := reflect.DeepEqual(yamlDirData, testdirData) 63 | if !eq { 64 | t.Error("Directory contents are not equal") 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export GO111MODULE=on 2 | VERSION="$(shell git describe --tags --candidates=1 --dirty)+envato" 3 | FLAGS=-X main.Version=$(VERSION) -s -w 4 | SOURCES=$(wildcard *.go iamy/*.go) 5 | 6 | # To create a new release: 7 | # $ git tag vx.x.x 8 | # $ git push --tags 9 | # $ make clean 10 | # $ make release # this will create 3 binaries in ./bin 11 | # 12 | # Next, go to https://github.com/99designs/iamy/releases/new 13 | # - select the tag version you just created 14 | # - Attach the binaries from ./bin/* 15 | 16 | release: bin/iamy-linux-arm64 bin/iamy-linux-amd64 bin/iamy-darwin-amd64 bin/iamy-windows-386.exe bin/iamy-darwin-arm64 bin/iamy-freebsd-amd64 17 | 18 | bin/iamy-darwin-arm64: $(SOURCES) 19 | @mkdir -p bin 20 | GOOS=darwin GOARCH=arm64 go build -o $@ -ldflags="$(FLAGS)" . 21 | 22 | bin/iamy-linux-arm64: $(SOURCES) 23 | @mkdir -p bin 24 | GOOS=linux GOARCH=arm64 go build -o $@ -ldflags="$(FLAGS)" . 25 | 26 | bin/iamy-linux-amd64: $(SOURCES) 27 | @mkdir -p bin 28 | GOOS=linux GOARCH=amd64 go build -o $@ -ldflags="$(FLAGS)" . 29 | 30 | bin/iamy-darwin-amd64: $(SOURCES) 31 | @mkdir -p bin 32 | GOOS=darwin GOARCH=amd64 go build -o $@ -ldflags="$(FLAGS)" . 33 | 34 | bin/iamy-windows-386.exe: $(SOURCES) 35 | @mkdir -p bin 36 | GOOS=windows GOARCH=386 go build -o $@ -ldflags="$(FLAGS)" . 37 | 38 | bin/iamy-freebsd-amd64: $(SOURCES) 39 | @mkdir -p bin 40 | GOOS=freebsd GOARCH=amd64 go build -o $@ -ldflags="$(FLAGS)" . 41 | 42 | clean: 43 | rm -f bin/* 44 | 45 | .PHONY: clean release 46 | -------------------------------------------------------------------------------- /iamy/cfn_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import "testing" 4 | 5 | func TestCfnMangedResources(t *testing.T) { 6 | t.Run("With fetched CFN resource lists", func(t *testing.T) { 7 | 8 | cfn := cfnClient{ 9 | managedResources: map[string]CfnResourceTypes{ 10 | "foobar": []CfnResourceType{CfnIamPolicy, CfnIamRole}, 11 | }, 12 | } 13 | 14 | if cfn.IsManagedResource(CfnIamUser, "foobar") { 15 | t.Fatal("different object types with same name is not managed") 16 | } 17 | 18 | if !cfn.IsManagedResource(CfnIamPolicy, "foobar") { 19 | t.Fatal("matching object and type should be managed") 20 | } 21 | }) 22 | 23 | t.Run("With heuristic matching", func(t *testing.T) { 24 | cfn := cfnClient{} 25 | 26 | if cfn.IsManagedResource(CfnIamUser, "foobar") { 27 | t.Fatal("names without id suffix are not managed") 28 | } 29 | 30 | if !cfn.IsManagedResource(CfnIamPolicy, "foobar-ABCDEFGH1234567") { 31 | t.Fatal("names with id suffix are managed") 32 | } 33 | 34 | if cfn.IsManagedResource(CfnIamPolicy, "foobar-abcdefgh1234567") { 35 | t.Fatal("names with suffix containing only lowercase letters are not managed") 36 | } 37 | 38 | if cfn.IsManagedResource(CfnIamPolicy, "elasticbeanstalk-us-east-1-298865909318") { 39 | t.Fatal("names ending with account numbers are managed") 40 | } 41 | 42 | if cfn.IsManagedResource(CfnIamPolicy, "Elasticbeanstalk-us-East-1-298865909318") { 43 | t.Fatal("names ending with account numbers are managed, even if the name contains uppercase letters") 44 | } 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /iamy/resourcegroupstagging.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/session" 8 | "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" 9 | "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" 10 | ) 11 | 12 | type resourceGroupsTaggingAPIClient struct { 13 | resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI 14 | } 15 | 16 | func newResourceGroupsTaggingAPIClient(sess *session.Session) *resourceGroupsTaggingAPIClient { 17 | // Force us of us-east-1 otherwise tags will not be returned for global resources 18 | return &resourceGroupsTaggingAPIClient{ 19 | resourcegroupstaggingapi.New(sess, aws.NewConfig().WithRegion("us-east-1")), 20 | } 21 | } 22 | 23 | func (c *resourceGroupsTaggingAPIClient) getMultiplePolicyTags(arns []*string) (map[string]map[string]string, error) { 24 | queryArns := make([]string, 0) 25 | for _, s := range arns { 26 | queryArns = append(queryArns, *s) 27 | } 28 | log.Println("Fetching tags for:", queryArns) 29 | 30 | res := make(map[string]map[string]string) 31 | if len(arns) == 0 { 32 | return res, nil 33 | } 34 | resp, err := c.GetResources(&resourcegroupstaggingapi.GetResourcesInput{ResourceARNList: arns}) 35 | if err != nil { 36 | return nil, err 37 | } 38 | for _, mapping := range resp.ResourceTagMappingList { 39 | res[*mapping.ResourceARN] = make(map[string]string) 40 | for _, tag := range mapping.Tags { 41 | res[*mapping.ResourceARN][*tag.Key] = *tag.Value 42 | } 43 | } 44 | return res, nil 45 | } 46 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "reflect" 9 | "regexp" 10 | 11 | "github.com/blang/semver/v4" 12 | ) 13 | 14 | func checkVersion() error { 15 | if Version != "dev" { 16 | requiredVersion, err := fetchRequiredVersion(versionFileName) 17 | if err != nil { 18 | return err 19 | } 20 | currentVersion, err := semver.ParseTolerant(Version) 21 | // Ignore Prepatches - iamy uses them to mark builds from uncommitted trees 22 | currentVersion.Pre = nil 23 | if err != nil { 24 | return err 25 | } 26 | ok, msg := versionOk(currentVersion, requiredVersion) 27 | if !ok { 28 | return msg 29 | } 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func fetchRequiredVersion(filename string) (semver.Version, error) { 36 | if _, err := os.Stat(versionFileName); !os.IsNotExist(err) { 37 | log.Printf("%s found", filename) 38 | fileBytes, _ := ioutil.ReadFile(filename) 39 | fileContents := string(fileBytes) 40 | 41 | if fileContents != "" { 42 | re := regexp.MustCompile(`\d\.\d+\.\d\+?\w*`) 43 | match := re.FindStringSubmatch(fileContents) 44 | return semver.Make(match[0]) 45 | } 46 | } 47 | return semver.Parse("0.0.0") 48 | } 49 | 50 | func versionOk(current semver.Version, required semver.Version) (bool, error) { 51 | if current.LT(required) { 52 | msg := fmt.Errorf(versionTooOldError, current, required) 53 | return false, msg 54 | } 55 | if len(required.Build) > 0 { 56 | // Pay attention to build tags as well if they are required 57 | if !reflect.DeepEqual(required.Build, current.Build) { 58 | msg := fmt.Errorf(buildVersionMismatch, current, required.Build, required) 59 | return false, msg 60 | } 61 | } 62 | return true, nil 63 | } 64 | -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestCheckVersionIsHigher(t *testing.T) { 6 | versionFileName = "fixtures/0.0.1" 7 | Version = "0.0.2" 8 | if err := checkVersion(); err != nil { 9 | t.Errorf("Received an unexpected error %s", err) 10 | } 11 | } 12 | 13 | func TestCheckVersionIsLower(t *testing.T) { 14 | versionFileName = "fixtures/0.0.2" 15 | Version = "0.0.1" 16 | if err := checkVersion(); err == nil { 17 | t.Error("Received no error", err) 18 | } 19 | } 20 | 21 | func TestCheckVersionIsTheSameWithBuildTag(t *testing.T) { 22 | versionFileName = "fixtures/0.0.2" 23 | Version = "0.0.2+envato" 24 | if err := checkVersion(); err != nil { 25 | t.Errorf("Received an unexpected error %s", err) 26 | } 27 | } 28 | 29 | func TestCheckVersionIsLowerWithBuildTag(t *testing.T) { 30 | versionFileName = "fixtures/0.0.2" 31 | Version = "0.0.1+envato" 32 | if err := checkVersion(); err == nil { 33 | t.Error("Received no error", err) 34 | } 35 | } 36 | 37 | func TestCheckVersionIsTheSameWithMatchingBuildTag(t *testing.T) { 38 | versionFileName = "fixtures/0.0.2.buildtag" 39 | Version = "0.0.2+buildtag" 40 | if err := checkVersion(); err != nil { 41 | t.Errorf("Received an unexpected error %s", err) 42 | } 43 | } 44 | 45 | func TestCheckVersionIsTheSameWithoutMatchingBuildTag(t *testing.T) { 46 | versionFileName = "fixtures/0.0.2.buildtag" 47 | Version = "0.0.2+notbuildtag" 48 | if err := checkVersion(); err == nil { 49 | t.Error("Received no error", err) 50 | } 51 | } 52 | 53 | func TestCheckVersionWithPreRelease(t *testing.T) { 54 | versionFileName = "fixtures/0.0.2" 55 | Version = "0.0.2-dirty" 56 | if err := checkVersion(); err != nil { 57 | t.Errorf("Received an unexpected error %s", err) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /fmt_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "io" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "testing" 10 | ) 11 | 12 | const accountAlias = "myaccount-123" 13 | 14 | var isDryRun = false 15 | var testDir = "" 16 | 17 | func mockUi(t *testing.T) Ui { 18 | return Ui{ 19 | Logger: log.New(io.Discard, "", 0), 20 | Error: log.New(io.Discard, "", 0), 21 | Debug: log.New(io.Discard, "", 0), 22 | Exit: func(code int) { t.Errorf("ui.Exit called with status %d", code) }, 23 | } 24 | } 25 | 26 | func TestMain(m *testing.M) { 27 | err := setup() 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | 32 | code := m.Run() 33 | 34 | err = teardown() 35 | if err != nil { 36 | log.Println(err) 37 | } 38 | 39 | os.Exit(code) 40 | } 41 | 42 | func setup() error { 43 | dryRun = &isDryRun 44 | tempDir, err := os.MkdirTemp("", "iamy-test-fmt") 45 | if err != nil { 46 | return err 47 | } 48 | 49 | policyDir := filepath.Join(tempDir, accountAlias, "iam", "policy") 50 | 51 | err = os.MkdirAll(policyDir, 0755) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | data, err := os.ReadFile("fixtures/resources/iam-policy-before-fmt.yaml") 57 | if err != nil { 58 | return err 59 | } 60 | 61 | err = os.WriteFile(filepath.Join(policyDir, "TestPolicy.yaml"), data, 0644) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | testDir = tempDir 67 | 68 | return nil 69 | } 70 | 71 | func teardown() error { 72 | if testDir != "" { 73 | err := os.RemoveAll(testDir) 74 | return err 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func TestNormalization(t *testing.T) { 81 | expected, err := os.ReadFile("fixtures/resources/iam-policy-after-fmt.yaml") 82 | if err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | input := FormatCommandInput{ 87 | Dir: testDir, 88 | CanDelete: true, 89 | } 90 | 91 | FormatCommand(mockUi(t), input) 92 | 93 | actual, err := os.ReadFile(filepath.Join(testDir, accountAlias, "iam", "policy", "TestPolicy.yaml")) 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | 98 | assert.Equal(t, expected, actual) 99 | } 100 | -------------------------------------------------------------------------------- /iamy/awsdiff_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func loadDataFrom(p string) *AccountData { 11 | d, err := os.Getwd() 12 | if err != nil { 13 | panic(err.Error()) 14 | } 15 | 16 | yamlLoader := YamlLoadDumper{ 17 | Dir: filepath.Join(d, "testdata", "awsdiff", p), 18 | } 19 | 20 | dd, err := yamlLoader.Load() 21 | if err != nil { 22 | panic(err.Error()) 23 | } 24 | if len(dd) < 1 { 25 | return &AccountData{} 26 | } 27 | return &dd[0] 28 | } 29 | 30 | func TestPolicyIsDetachedFromRoleBeforeUpdate(t *testing.T) { 31 | localData := loadDataFrom("testcase1-local") 32 | remoteData := loadDataFrom("testcase1-remote") 33 | awsCmds := AwsCliCmdsForSync(remoteData, localData) 34 | 35 | expected := strings.Join([]string{ 36 | "aws iam detach-role-policy --role-name testrole --policy-arn arn:aws:iam::123:policy/test", 37 | "aws iam delete-policy --policy-arn arn:aws:iam::123:policy/test", 38 | }, "\n") 39 | actual := awsCmds.String() 40 | 41 | if actual != expected { 42 | 43 | t.Errorf(`Expected: 44 | %v 45 | Actual: 46 | %v`, expected, actual) 47 | 48 | } 49 | } 50 | 51 | func TestCreateRoleWithMaxSessionDuration(t *testing.T) { 52 | localData := loadDataFrom("max-session-duration-local") 53 | remoteData := loadDataFrom("max-session-duration-remote1") 54 | awsCmds := AwsCliCmdsForSync(remoteData, localData) 55 | 56 | expected := strings.Join([]string{ 57 | `aws iam create-role --role-name testrole --path / --assume-role-policy-document '{ 58 | "Statement": [ 59 | { 60 | "Action": "sts:AssumeRole", 61 | "Effect": "Allow", 62 | "Principal": { 63 | "AWS": "arn:aws:iam::123:root" 64 | }, 65 | "Sid": "" 66 | } 67 | ], 68 | "Version": "2012-10-17" 69 | }' --max-session-duration 7200`, 70 | }, "\n") 71 | actual := awsCmds.String() 72 | 73 | if actual != expected { 74 | 75 | t.Errorf(`Expected: 76 | %v 77 | Actual: 78 | %v`, expected, actual) 79 | 80 | } 81 | } 82 | 83 | func TestUpdateRoleWithMaxSessionDuration(t *testing.T) { 84 | localData := loadDataFrom("max-session-duration-local") 85 | remoteData := loadDataFrom("max-session-duration-remote2") 86 | awsCmds := AwsCliCmdsForSync(remoteData, localData) 87 | 88 | expected := strings.Join([]string{ 89 | "aws iam update-role --role-name testrole --max-session-duration 7200", 90 | }, "\n") 91 | actual := awsCmds.String() 92 | 93 | if actual != expected { 94 | 95 | t.Errorf(`Expected: 96 | %v 97 | Actual: 98 | %v`, expected, actual) 99 | 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /iamy/policy_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | type normaliseTest struct { 9 | description string 10 | input interface{} 11 | expected interface{} 12 | } 13 | 14 | var normaliseTests = []normaliseTest{ 15 | { 16 | "data not requiring normalisation should not change", 17 | map[string]interface{}{ 18 | "a": "1", 19 | "b": map[string]string{ 20 | "aa": "11", 21 | "bb": "22", 22 | }, 23 | "c": []string{ 24 | "11", 25 | "22", 26 | }, 27 | }, 28 | map[string]interface{}{ 29 | "a": "1", 30 | "b": map[string]string{ 31 | "aa": "11", 32 | "bb": "22", 33 | }, 34 | "c": []string{ 35 | "11", 36 | "22", 37 | }, 38 | }, 39 | }, 40 | 41 | { 42 | "slice with length of one should be normalised", 43 | map[string]interface{}{ 44 | "a": []string{ 45 | "11", 46 | }, 47 | }, 48 | map[string]interface{}{ 49 | "a": "11", 50 | }, 51 | }, 52 | 53 | { 54 | "string slice should get sorted", 55 | map[string]interface{}{ 56 | "sort-test": []string{ 57 | "r", 58 | "t", 59 | "a", 60 | "d", 61 | }, 62 | }, 63 | map[string]interface{}{ 64 | "sort-test": []string{ 65 | "a", 66 | "d", 67 | "r", 68 | "t", 69 | }, 70 | }, 71 | }, 72 | 73 | { 74 | "interface slice should get sorted", 75 | map[string]interface{}{ 76 | "sort-test": []interface{}{ 77 | "r", 78 | "t", 79 | "a", 80 | "d", 81 | }, 82 | }, 83 | map[string]interface{}{ 84 | "sort-test": []interface{}{ 85 | "a", 86 | "d", 87 | "r", 88 | "t", 89 | }, 90 | }, 91 | }, 92 | 93 | { 94 | "nested interface slice should get sorted", 95 | []interface{}{ 96 | map[string]interface{}{ 97 | "sort-test": []interface{}{ 98 | "r", 99 | "t", 100 | "a", 101 | "d", 102 | }, 103 | }, 104 | }, 105 | []interface{}{ 106 | map[string]interface{}{ 107 | "sort-test": []interface{}{ 108 | "a", 109 | "d", 110 | "r", 111 | "t", 112 | }, 113 | }, 114 | }, 115 | }, 116 | } 117 | 118 | func TestRecursivelyNormaliseAwsPolicy(t *testing.T) { 119 | for _, nt := range normaliseTests { 120 | result := recursivelyNormaliseAwsPolicy(nt.input) 121 | if !reflect.DeepEqual(result, nt.expected) { 122 | t.Errorf(`%s. 123 | Input: %#v 124 | Expected %#v 125 | Actual: %#v`, nt.description, nt.input, nt.expected, result) 126 | } 127 | } 128 | } 129 | 130 | func TestNewPolicyDocumentFromJson(t *testing.T) { 131 | _, err := NewPolicyDocumentFromJson(`{"Version":"2012-10-17","Id":"AllowPublicRead","Statement":[{"Sid":"PublicReadBucketObjects","Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::example.com/*","Condition":{"StringEquals":{"aws:Referer":"%zz"}}}]}`) 132 | if err != nil { 133 | t.Errorf("Error decoding policy %s", err) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /iamy/iam.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/session" 6 | "github.com/aws/aws-sdk-go/service/iam" 7 | "github.com/aws/aws-sdk-go/service/iam/iamiface" 8 | ) 9 | 10 | type iamClient struct { 11 | iamiface.IAMAPI 12 | } 13 | 14 | func newIamClient(sess *session.Session) *iamClient { 15 | return &iamClient{ 16 | iam.New(sess), 17 | } 18 | } 19 | 20 | func (c *iamClient) getPolicyDescription(arn string) (string, error) { 21 | resp, err := c.GetPolicy(&iam.GetPolicyInput{PolicyArn: &arn}) 22 | if err == nil && resp.Policy.Description != nil { 23 | return *resp.Policy.Description, nil 24 | } 25 | return "", err 26 | } 27 | 28 | func (c *iamClient) getPolicyTags(arn string) (map[string]string, error) { 29 | resp, err := c.ListPolicyTags(&iam.ListPolicyTagsInput{PolicyArn: &arn}) 30 | if err == nil && resp.Tags != nil { 31 | tags := make(map[string]string) 32 | for _, tag := range resp.Tags { 33 | tags[*tag.Key] = *tag.Value 34 | } 35 | return tags, err 36 | } 37 | return nil, err 38 | } 39 | 40 | func (c *iamClient) getInstanceProfileTags(name string) (map[string]string, error) { 41 | resp, err := c.ListInstanceProfileTags(&iam.ListInstanceProfileTagsInput{InstanceProfileName: &name}) 42 | if err == nil && resp.Tags != nil { 43 | tags := make(map[string]string) 44 | for _, tag := range resp.Tags { 45 | tags[*tag.Key] = *tag.Value 46 | } 47 | return tags, err 48 | } 49 | return nil, err 50 | } 51 | 52 | func (c *iamClient) getRole(name string) (string, int, error) { 53 | resp, err := c.GetRole(&iam.GetRoleInput{RoleName: &name}) 54 | if err != nil { 55 | panic(err) 56 | } 57 | var sessionDuration int64 58 | var description string 59 | // 3600 is the default, so let's ignore it 60 | if resp.Role.MaxSessionDuration != nil && *resp.Role.MaxSessionDuration != 3600 { 61 | sessionDuration = *resp.Role.MaxSessionDuration 62 | } 63 | 64 | if resp.Role.Description != nil { 65 | description = *resp.Role.Description 66 | } 67 | return description, int(sessionDuration), err 68 | } 69 | 70 | func (c *iamClient) MustGetSecurityCredsForUser(username string) (accessKeyIds, mfaIds []string, hasLoginProfile bool) { 71 | // access keys 72 | listUsersResp, err := c.ListAccessKeys(&iam.ListAccessKeysInput{ 73 | UserName: aws.String(username), 74 | }) 75 | if err != nil { 76 | panic(err) 77 | } 78 | for _, m := range listUsersResp.AccessKeyMetadata { 79 | accessKeyIds = append(accessKeyIds, *m.AccessKeyId) 80 | } 81 | 82 | // mfa devices 83 | mfaResp, err := c.ListMFADevices(&iam.ListMFADevicesInput{ 84 | UserName: aws.String(username), 85 | }) 86 | if err != nil { 87 | panic(err) 88 | } 89 | for _, m := range mfaResp.MFADevices { 90 | mfaIds = append(mfaIds, *m.SerialNumber) 91 | } 92 | 93 | // login profile 94 | _, err = c.GetLoginProfile(&iam.GetLoginProfileInput{ 95 | UserName: aws.String(username), 96 | }) 97 | if err == nil { 98 | hasLoginProfile = true 99 | } 100 | 101 | return 102 | } 103 | -------------------------------------------------------------------------------- /push.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/envato/iamy/iamy" 11 | "github.com/fatih/color" 12 | ) 13 | 14 | type PushCommandInput struct { 15 | Dir string 16 | HeuristicCfnMatching bool 17 | SkipTagged []string 18 | IncludeTagged []string 19 | SkipPathPrefixes []string 20 | } 21 | 22 | func PushCommand(ui Ui, input PushCommandInput) { 23 | yaml := iamy.YamlLoadDumper{ 24 | Dir: input.Dir, 25 | } 26 | aws := iamy.AwsFetcher{ 27 | SkipFetchingPolicyAndRoleDescriptions: false, 28 | Debug: ui.Debug, 29 | HeuristicCfnMatching: input.HeuristicCfnMatching, 30 | SkipTagged: input.SkipTagged, 31 | IncludeTagged: input.IncludeTagged, 32 | SkipPathPrefixes: input.SkipPathPrefixes, 33 | } 34 | 35 | allDataFromYaml, err := yaml.Load() 36 | if err != nil { 37 | ui.Fatal(err) 38 | return 39 | } 40 | 41 | dataFromAws, err := aws.Fetch() 42 | if err != nil { 43 | ui.Fatal(err) 44 | return 45 | } 46 | 47 | // find the yaml account data that matches the aws account 48 | for _, dataFromYaml := range allDataFromYaml { 49 | if dataFromYaml.Account.Id == dataFromAws.Account.Id { 50 | sync(dataFromYaml, dataFromAws, ui) 51 | return 52 | } 53 | } 54 | 55 | ui.Println("No files found for AWS Account ID " + dataFromAws.Account.Id) 56 | } 57 | 58 | func printCommands(prefix string, awsCmds iamy.CmdList, ui Ui) { 59 | for _, cmd := range awsCmds { 60 | cmdStr := cmd.String() 61 | if cmd.IsDestructive() { 62 | cmdStr = color.RedString(cmdStr) 63 | } 64 | ui.Println(prefix + cmdStr) 65 | } 66 | } 67 | 68 | func sync(yamlData iamy.AccountData, awsData *iamy.AccountData, ui Ui) { 69 | ui.Debug.Printf("Generating sync commands for %s", awsData.Account.String()) 70 | 71 | awsCmds := iamy.AwsCliCmdsForSync(awsData, &yamlData) 72 | if len(awsCmds) == 0 { 73 | ui.Println("Already up to date") 74 | return 75 | } 76 | 77 | ui.Println("Commands to push changes to AWS:") 78 | 79 | printCommands(" ", awsCmds, ui) 80 | 81 | if *dryRun { 82 | ui.Println("Dry-run mode not running aws commands") 83 | return 84 | } 85 | r, err := prompt(fmt.Sprintf("\nRun %d aws commands (%d destructive)? (y/N) ", awsCmds.Count(), awsCmds.CountDestructive())) 86 | if err != nil { 87 | ui.Fatal(err) 88 | return 89 | } 90 | if r == "y" { 91 | for _, c := range awsCmds { 92 | execCmd(c, ui) 93 | } 94 | } else { 95 | ui.Println("Not running aws commands") 96 | } 97 | } 98 | 99 | func execCmd(c iamy.Cmd, ui Ui) { 100 | ui.Println("\n>", c) 101 | cmd := exec.Command(c.Name, c.Args...) 102 | cmd.Stdout = os.Stdout 103 | cmd.Stderr = os.Stderr 104 | err := cmd.Run() 105 | if err != nil { 106 | ui.Fatal(err) 107 | return 108 | } 109 | } 110 | 111 | func prompt(prompt string) (string, error) { 112 | reader := bufio.NewReader(os.Stdin) 113 | fmt.Print(prompt) 114 | text, err := reader.ReadString('\n') 115 | if err != nil { 116 | return "", err 117 | } 118 | return strings.TrimSpace(text), nil 119 | } 120 | -------------------------------------------------------------------------------- /iamy/awsaccountid.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "strings" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/ec2" 11 | "github.com/aws/aws-sdk-go/service/iam" 12 | "github.com/aws/aws-sdk-go/service/sts" 13 | ) 14 | 15 | // GetAwsAccountId determines the AWS account id associated 16 | // with the given session 17 | func GetAwsAccountId(sess *session.Session, debug *log.Logger) (string, error) { 18 | debug.Println("Finding AWS account ID via GetCallerIdentity") 19 | accountid, err := determineAccountIdViaGetCallerIdentity(sess) 20 | if err == nil { 21 | debug.Println("AWS account ID:", accountid) 22 | return accountid, nil 23 | } 24 | 25 | debug.Println("Finding AWS account ID via GetUser") 26 | accountid, err = determineAccountIdViaGetUser(sess) 27 | if err == nil { 28 | debug.Println("AWS account ID:", accountid) 29 | return accountid, nil 30 | } 31 | 32 | debug.Println("Finding AWS account ID via ListUsers") 33 | accountid, err = determineAccountIdViaListUsers(sess) 34 | if err == nil { 35 | debug.Println("AWS account ID:", accountid) 36 | return accountid, nil 37 | } 38 | 39 | debug.Println("Finding AWS account ID via DefaultSecurityGroup") 40 | accountid, err = determineAccountIdViaDefaultSecurityGroup(sess) 41 | if err == nil { 42 | debug.Println("AWS account ID:", accountid) 43 | return accountid, nil 44 | } 45 | 46 | return "", errors.New("Can't determine the AWS account id") 47 | } 48 | 49 | func getAccountIdFromArn(arn string) string { 50 | s := strings.Split(arn, ":") 51 | return s[4] 52 | } 53 | 54 | // https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html 55 | func determineAccountIdViaGetCallerIdentity(sess *session.Session) (string, error) { 56 | resp, err := sts.New(sess).GetCallerIdentity(&sts.GetCallerIdentityInput{}) 57 | if err != nil { 58 | return "", err 59 | } 60 | return *resp.Account, nil 61 | } 62 | 63 | // see http://stackoverflow.com/a/18124234 64 | func determineAccountIdViaGetUser(sess *session.Session) (string, error) { 65 | getUserResp, err := iam.New(sess).GetUser(&iam.GetUserInput{}) 66 | if err != nil { 67 | return "", err 68 | } 69 | 70 | return getAccountIdFromArn(*getUserResp.User.Arn), nil 71 | } 72 | 73 | func determineAccountIdViaListUsers(sess *session.Session) (string, error) { 74 | listUsersResp, err := iam.New(sess).ListUsers(&iam.ListUsersInput{}) 75 | if err != nil { 76 | return "", err 77 | } 78 | if len(listUsersResp.Users) == 0 { 79 | return "", errors.New("No users found") 80 | } 81 | 82 | return getAccountIdFromArn(*listUsersResp.Users[0].Arn), nil 83 | } 84 | 85 | // see http://stackoverflow.com/a/30578645 86 | func determineAccountIdViaDefaultSecurityGroup(sess *session.Session) (string, error) { 87 | sg, err := ec2.New(sess).DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{ 88 | GroupNames: []*string{ 89 | aws.String("default"), 90 | }, 91 | }) 92 | if err != nil { 93 | return "", err 94 | } 95 | if len(sg.SecurityGroups) == 0 { 96 | return "", errors.New("No security groups found") 97 | } 98 | 99 | return *sg.SecurityGroups[0].OwnerId, nil 100 | } 101 | -------------------------------------------------------------------------------- /iamy/policy.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/url" 7 | "reflect" 8 | "sort" 9 | ) 10 | 11 | func NewPolicyDocumentFromJson(jsonString string) (*PolicyDocument, error) { 12 | var doc PolicyDocument 13 | if err := json.Unmarshal([]byte(jsonString), &doc); err != nil { 14 | log.Printf("Error unmarshalling JSON %s %s", err, jsonString) 15 | return nil, err 16 | } 17 | 18 | return &doc, nil 19 | } 20 | 21 | func NewPolicyDocumentFromEncodedJson(encoded string) (*PolicyDocument, error) { 22 | jsonString, err := url.QueryUnescape(encoded) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return NewPolicyDocumentFromJson(jsonString) 28 | } 29 | 30 | // PolicyDocument represents an AWS policy document. 31 | // It normalises the data when Marshaling and Unmarshaling JSON 32 | // the same way AWS does to avoid conflicts when diffing 33 | type PolicyDocument struct { 34 | data interface{} 35 | } 36 | 37 | func (p *PolicyDocument) JsonString() string { 38 | jsonBytes, err := json.MarshalIndent(p, "", " ") 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | return string(jsonBytes) 44 | } 45 | 46 | func (p PolicyDocument) MarshalJSON() ([]byte, error) { 47 | return json.Marshal(recursivelyNormaliseAwsPolicy(p.data)) 48 | } 49 | 50 | func (p *PolicyDocument) UnmarshalJSON(jsonData []byte) error { 51 | err := json.Unmarshal(jsonData, &p.data) 52 | p.data = recursivelyNormaliseAwsPolicy(p.data) 53 | return err 54 | } 55 | 56 | // RecursivelyNormaliseAwsPolicy recursively searches i for slices 57 | // and normalises 58 | // 1. slices of length 1 become single strings 59 | // 2. slices of length > 1 are sorted 60 | func recursivelyNormaliseAwsPolicy(i interface{}) interface{} { 61 | 62 | switch reflect.TypeOf(i).Kind() { 63 | 64 | case reflect.Map: 65 | origMap := reflect.ValueOf(i) 66 | newMap := reflect.MakeMap(origMap.Type()) 67 | for _, key := range origMap.MapKeys() { 68 | originalValue := origMap.MapIndex(key).Interface() 69 | newValue := recursivelyNormaliseAwsPolicy(originalValue) 70 | newMap.SetMapIndex(key, reflect.ValueOf(newValue)) 71 | } 72 | return newMap.Interface() 73 | 74 | case reflect.Slice: 75 | if ss, ok := i.([]string); ok { 76 | if len(ss) == 1 { 77 | return ss[0] 78 | } 79 | sort.Strings(ss) 80 | return ss 81 | } 82 | 83 | if ii, ok := i.([]interface{}); ok { 84 | if len(ii) > 0 { 85 | // if it's actually a string slice 86 | if _, ok := ii[0].(string); ok { 87 | if len(ii) == 1 { 88 | return ii[0] 89 | } 90 | ss := interfaceSliceToStringSlice(ii) 91 | sort.Strings(ss) 92 | return stringSliceToInterfaceSlice(ss) 93 | } else { 94 | origSlice := reflect.ValueOf(ii) 95 | newSlice := reflect.MakeSlice(origSlice.Type(), 0, origSlice.Cap()) 96 | for _, originalValue := range ii { 97 | newValue := recursivelyNormaliseAwsPolicy(originalValue) 98 | newSlice = reflect.Append(newSlice, reflect.ValueOf(newValue)) 99 | } 100 | return newSlice.Interface() 101 | } 102 | } 103 | } 104 | } 105 | 106 | return i 107 | } 108 | 109 | func interfaceSliceToStringSlice(a []interface{}) []string { 110 | b := make([]string, len(a)) 111 | for i := range a { 112 | b[i] = a[i].(string) 113 | } 114 | return b 115 | } 116 | 117 | func stringSliceToInterfaceSlice(a []string) []interface{} { 118 | b := make([]interface{}, len(a)) 119 | for i := range a { 120 | b[i] = a[i] 121 | } 122 | return b 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IAMy 2 | 3 | IAMy is a tool for dumping and loading your AWS IAM configuration into YAML files. 4 | 5 | This allows you to use an [Infrastructure as Code](https://en.wikipedia.org/wiki/Infrastructure_as_Code) model to manage your IAM configuration. For example, you might use a github repo with a pull request model for changes to IAM config. 6 | 7 | This code was originally developed by 99designs ([origin upstream](https://github.com/99designs/iamy.git)), we recognise and appreciate the enormous effort they have put into this tool. 8 | This particular version has been cloned to allow Envato to rapidly develop the features that are important to our use of this tool, we are following the existing semver arrangements for the repository, but we've appended a envato build tag. 9 | 10 | # Additional features 11 | 12 | Features added to this fork include: 13 | - .iamy-version file support, [Original PR](https://github.com/99designs/iamy/pull/63) 14 | - Flags to skip resources by tag (`--skip-tagged the-tag-name` and `--skip-cfn-tagged`) 15 | - .iamy-flags file support for default flags. Flags are appended to command line supplied flags. Example .iamy-flags file 16 | contents: `--skip-tagged=iamy-ignore`. 17 | - `iamy fmt`, which formats files to match the result of `iamy pull` 18 | - Add support for specifying [MaxSessionDuration](https://aws.amazon.com/about-aws/whats-new/2018/03/longer-role-sessions/) on a role 19 | 20 | # Upcoming features 21 | 22 | The additional features we are likely to add to this fork are: 23 | - support for organizations, ous and scps 24 | 25 | # Installation 26 | 27 | ``` 28 | brew tap envato/envato-iamy 29 | brew install envato/envato-iamy/iamy 30 | ``` 31 | 32 | # Development Status 33 | 34 | Under active development, pull requests welcome. Open issues for discussions please. 35 | 36 | ## How it works 37 | 38 | IAMy has two main subcommands. 39 | 40 | `pull` will sync IAM users, groups and policies from AWS to YAML files 41 | 42 | `push` will sync IAM users, groups and policies from YAML files to AWS 43 | 44 | For the `push` command, IAMy will output an execution plan as a series of [`aws` cli](https://aws.amazon.com/cli/) commands which can be optionally executed. This turns out to be a very direct and understandable way to display the changes to be made, and means you can pick and choose exactly what commands get actioned. 45 | 46 | ### Other features 47 | 48 | - `fmt` will reformat all relevant files to match the output of `iamy pull`. This is particularly useful for using IAMy for drift detection, as you can use it as a PR check, and/or reformat files before performing a diff. 49 | 50 | ## Getting started 51 | 52 | You can install IAMy on macOS with `brew install iamy`, or with the go toolchain `go get -u github.com/99designs/iamy`. 53 | 54 | Because IAMy uses the [aws cli tool](https://aws.amazon.com/cli/), you'll want to install it first. 55 | 56 | For configuration, IAMy uses the same [AWS environment variables](http://docs.aws.amazon.com/cli/latest/userguide/cli-environment.html) as the aws cli. You might find [aws-vault](https://github.com/99designs/aws-vault) an excellent complementary tool for managing AWS credentials. 57 | 58 | 59 | ## Example Usage 60 | 61 | ```bash 62 | $ iamy pull 63 | 64 | $ find . 65 | ./myaccount-123456789/iam/user/joe.yml 66 | 67 | $ mkdir -p myaccount-123456789/iam/user/foo 68 | 69 | $ touch myaccount-123456789/iam/user/foo/bar.baz 70 | 71 | $ cat << EOD > myaccount-123456789/iam/user/billy.blogs 72 | Policies: 73 | - arn:aws:iam::aws:policy/ReadOnly 74 | EOD 75 | 76 | $ iamy push 77 | Commands to push changes to AWS: 78 | aws iam create-user --path /foo --user-name bar.baz 79 | aws iam create-user --user-name billy.blogs 80 | aws iam attach-user-policy --user-name billy.blogs --policy-arn arn:aws:iam::aws:policy/ReadOnly 81 | 82 | Exec all aws commands? (y/N) y 83 | 84 | > aws iam create-user --path /foo --user-name bar.baz 85 | > aws iam create-user --user-name billy.blogs 86 | > aws iam attach-user-policy --user-name billy.blogs --policy-arn arn:aws:iam::aws:policy/ReadOnly 87 | ``` 88 | 89 | ## Accurate cloudformation matching 90 | 91 | By default, iamy will use a simple heuristic (does it end with an ID, eg -ABCDEF1234) to determine if a given resource is managed by cloudformation. 92 | 93 | This behaviour is good enough for some cases, but if you want slower but more accurate matching pass `--accurate-cfn` 94 | to enumerate all cloudformation stacks and resources to determine exactly which resources are managed. 95 | 96 | ## Inspiration and similar tools 97 | - https://github.com/percolate/iamer 98 | - https://github.com/hashicorp/terraform 99 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= 2 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= 4 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 5 | github.com/aws/aws-sdk-go v1.42.39 h1:6Lso73VoCI8Zmv3zAMv4BNg2gHAKNOlbLv1s/ew90SI= 6 | github.com/aws/aws-sdk-go v1.42.39/go.mod h1:OGr6lGMAKGlG9CVrYnWYDKIyb829c6EVBRjxqjmPepc= 7 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 8 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= 13 | github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= 14 | github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= 15 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 16 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 17 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 18 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 19 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 20 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 21 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 22 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 23 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 24 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 25 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 26 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 27 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 28 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 29 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 30 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 34 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 35 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 36 | golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM= 37 | golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 38 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= 40 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 42 | golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 43 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 44 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 45 | gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= 46 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 48 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 49 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 51 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 52 | -------------------------------------------------------------------------------- /iamy/s3.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/aws/awserr" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/aws/aws-sdk-go/service/s3" 11 | "github.com/aws/aws-sdk-go/service/s3/s3iface" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | const NoSuchBucketPolicyErrCode = "NoSuchBucketPolicy" 16 | const NoSuchTagSetErrCode = "NoSuchTagSet" 17 | 18 | func newRegionClientMap(s *session.Session) *regionClientMap { 19 | return ®ionClientMap{ 20 | clients: map[string]s3iface.S3API{}, 21 | sess: s, 22 | mutex: &sync.Mutex{}, 23 | } 24 | } 25 | 26 | type regionClientMap struct { 27 | sess *session.Session 28 | clients map[string]s3iface.S3API 29 | mutex *sync.Mutex 30 | } 31 | 32 | func (scm *regionClientMap) add(c *s3.S3) { 33 | scm.clients[*c.Config.Region] = c 34 | } 35 | 36 | func (scm *regionClientMap) getOrCreate(region string) s3iface.S3API { 37 | scm.mutex.Lock() 38 | if _, ok := scm.clients[region]; !ok { 39 | scm.clients[region] = s3.New(scm.sess, aws.NewConfig().WithRegion(region)) 40 | } 41 | scm.mutex.Unlock() 42 | 43 | return scm.clients[region] 44 | } 45 | 46 | type s3Client struct { 47 | s3iface.S3API 48 | regionClients *regionClientMap 49 | } 50 | 51 | func newS3Client(s *session.Session) *s3Client { 52 | defaultClient := s3.New(s) 53 | clients := newRegionClientMap(s) 54 | clients.add(defaultClient) 55 | 56 | return &s3Client{ 57 | S3API: defaultClient, 58 | regionClients: clients, 59 | } 60 | } 61 | 62 | type bucket struct { 63 | name string 64 | policyJson string 65 | exists bool 66 | tags map[string]string 67 | } 68 | 69 | func (c *s3Client) withRegion(region string) s3iface.S3API { 70 | if region == "" { 71 | return c.S3API 72 | } 73 | 74 | return c.regionClients.getOrCreate(region) 75 | } 76 | 77 | func normaliseString(a *string) (b string) { 78 | if a != nil { 79 | b = *a 80 | } 81 | return 82 | } 83 | 84 | func (c *s3Client) populateBucket(b *bucket) error { 85 | r, err := c.GetBucketLocation(&s3.GetBucketLocationInput{Bucket: aws.String(b.name)}) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | region := s3.NormalizeBucketLocation(normaliseString(r.LocationConstraint)) 91 | 92 | tags, err := c.fetchTags(b.name, region) 93 | if err != nil { 94 | return err 95 | } 96 | b.tags = tags 97 | 98 | b.policyJson, err = c.GetBucketPolicyDoc(b.name, region) 99 | 100 | return err 101 | } 102 | 103 | func (c *s3Client) listAllBuckets() ([]*bucket, error) { 104 | bucketListResp, err := c.ListBuckets(&s3.ListBucketsInput{}) 105 | if err != nil { 106 | return nil, errors.Wrap(err, "Error while calling ListBuckets") 107 | } 108 | 109 | var wg sync.WaitGroup 110 | var oneOfTheErrorsDuringPopulation error 111 | buckets := []*bucket{} 112 | 113 | for _, rb := range bucketListResp.Buckets { 114 | b := bucket{name: *rb.Name} 115 | b.exists = true 116 | buckets = append(buckets, &b) 117 | 118 | wg.Add(1) 119 | go func() { 120 | defer wg.Done() 121 | err := c.populateBucket(&b) 122 | if err != nil { 123 | if awsErr, ok := err.(awserr.Error); ok { 124 | if awsErr.Code() != s3.ErrCodeNoSuchBucket { 125 | oneOfTheErrorsDuringPopulation = errors.New(fmt.Sprintf("Error while getting details for S3 bucket %s: %s", b.name, err)) 126 | } 127 | } 128 | } 129 | }() 130 | } 131 | wg.Wait() 132 | 133 | bucketsExist := []*bucket{} 134 | 135 | for _, b := range buckets { 136 | if b.exists { 137 | bucketsExist = append(bucketsExist, b) 138 | } 139 | } 140 | 141 | if oneOfTheErrorsDuringPopulation != nil { 142 | return nil, oneOfTheErrorsDuringPopulation 143 | } 144 | 145 | return bucketsExist, nil 146 | } 147 | 148 | func (c *s3Client) GetBucketPolicyDoc(name, region string) (string, error) { 149 | clientForRegion := c.withRegion(region) 150 | resp, err := clientForRegion.GetBucketPolicy(&s3.GetBucketPolicyInput{ 151 | Bucket: aws.String(name), 152 | }) 153 | if err != nil { 154 | if awsErr, ok := err.(awserr.Error); ok { 155 | if awsErr.Code() == NoSuchBucketPolicyErrCode { 156 | return "", nil 157 | } 158 | } 159 | return "", fmt.Errorf("GetBucketPolicyDoc for %s: %s", name, err.Error()) 160 | } 161 | 162 | return *resp.Policy, nil 163 | } 164 | 165 | func (c *s3Client) fetchTags(name, region string) (map[string]string, error) { 166 | tags := make(map[string]string) 167 | clientForRegion := c.withRegion(region) 168 | tagsResponse, err := clientForRegion.GetBucketTagging(&s3.GetBucketTaggingInput{Bucket: aws.String(name)}) 169 | if err != nil { 170 | if awsErr, ok := err.(awserr.Error); ok { 171 | if awsErr.Code() == NoSuchTagSetErrCode { 172 | return tags, nil 173 | } 174 | } 175 | return tags, err 176 | } 177 | for _, tag := range tagsResponse.TagSet { 178 | if tag != nil { 179 | tags[*tag.Key] = *tag.Value 180 | } 181 | } 182 | return tags, nil 183 | } 184 | -------------------------------------------------------------------------------- /iamy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | 10 | "gopkg.in/alecthomas/kingpin.v2" 11 | ) 12 | 13 | const versionTooOldError = "Your version of IAMy (%s) is out of date compared to what the local project expects. You should upgrade to %s to use this project.\n" 14 | const buildVersionMismatch = "Your version of IAMy (%s) does not match the build tag (%s) the local project requires. You should upgrade to %s to use this project.\n" 15 | 16 | var ( 17 | Version string = "dev" 18 | defaultDir string 19 | dryRun *bool 20 | versionFileName string = ".iamy-version" 21 | configFileName string = ".iamy-flags" 22 | ) 23 | 24 | type logWriter struct{ *log.Logger } 25 | 26 | func (w logWriter) Write(b []byte) (int, error) { 27 | w.Printf("%s", b) 28 | return len(b), nil 29 | } 30 | 31 | type Ui struct { 32 | *log.Logger 33 | Error, Debug *log.Logger 34 | Exit func(code int) 35 | } 36 | 37 | // CFN automatically tags resources with this and other tags: 38 | // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-resource-tags.html 39 | const cloudformationStackNameTag = "aws:cloudformation:stack-name" 40 | 41 | func main() { 42 | var ( 43 | debug = kingpin.Flag("debug", "Show debugging output").Bool() 44 | skipCfnTagged = kingpin.Flag("skip-cfn-tagged", fmt.Sprintf("Shorthand for --skip-tagged %s", cloudformationStackNameTag)).Bool() 45 | skipTagged = kingpin.Flag("skip-tagged", "Skips IAM entities (or buckets associated with bucket policies) tagged with a given tag").Strings() 46 | includeTagged = kingpin.Flag("include-tagged", "Includes IAM entities (or buckets associated with bucket policies) tagged with a given tag").Strings() 47 | skipPathPrefixes = kingpin.Flag("skip-path-prefix", fmt.Sprintf("Skips IAM entities that have a path starting with the supplied prefix, repeat flag for multiple prefixes")).Strings() 48 | pull = kingpin.Command("pull", "Syncs IAM users, groups and policies from the active AWS account to files") 49 | pullDir = pull.Flag("dir", "The directory to dump yaml files to").Default(defaultDir).Short('d').String() 50 | pullCanDelete = pull.Flag("delete", "Delete extraneous files from destination dir").Bool() 51 | lookupCfn = pull.Flag("accurate-cfn", "Fetch all known resource names from cloudformation to get exact filtering").Bool() 52 | push = kingpin.Command("push", "Syncs IAM users, groups and policies from files to the active AWS account") 53 | pushDir = push.Flag("dir", "The directory to load yaml files from").Default(defaultDir).Short('d').ExistingDir() 54 | format = kingpin.Command("fmt", "Update YAML files to match expected format") 55 | formatDir = format.Flag("dir", "The base directory to format").Default(defaultDir).Short('d').ExistingDir() 56 | formatCanDelete = format.Flag("delete", "Delete extraneous files from destination dir").Bool() 57 | ) 58 | dryRun = kingpin.Flag("dry-run", "Show what would happen, but don't prompt to do it").Bool() 59 | 60 | kingpin.Version(Version) 61 | kingpin.CommandLine.Help = 62 | `Read and write AWS IAM users, policies, groups and roles from YAML files.` 63 | 64 | ui := Ui{ 65 | Logger: log.New(os.Stdout, "", 0), 66 | Error: log.New(os.Stderr, "", 0), 67 | Debug: log.New(ioutil.Discard, "", 0), 68 | Exit: os.Exit, 69 | } 70 | 71 | args := os.Args[1:] 72 | var configFileArgs []string 73 | if _, err := os.Stat(configFileName); err == nil { 74 | configFileArgs, err = kingpin.ExpandArgsFromFile(configFileName) 75 | if err != nil { 76 | panic(err) 77 | } 78 | args = append(args, configFileArgs...) 79 | } 80 | cmd, err := kingpin.CommandLine.Parse(args) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | if *debug { 86 | ui.Debug = log.New(os.Stderr, "DEBUG ", log.LstdFlags) 87 | log.SetFlags(0) 88 | log.SetOutput(&logWriter{ui.Debug}) 89 | if len(configFileArgs) > 0 { 90 | ui.Debug.Printf("Found flags in %s: %s", configFileName, configFileArgs) 91 | } 92 | } else { 93 | log.SetOutput(ioutil.Discard) 94 | } 95 | 96 | if err := checkVersion(); err != nil { 97 | panic(err) 98 | } 99 | 100 | if *skipCfnTagged { 101 | *skipTagged = append(*skipTagged, cloudformationStackNameTag) 102 | } 103 | 104 | switch cmd { 105 | case push.FullCommand(): 106 | PushCommand(ui, PushCommandInput{ 107 | Dir: *pushDir, 108 | HeuristicCfnMatching: !*lookupCfn, 109 | SkipTagged: *skipTagged, 110 | IncludeTagged: *includeTagged, 111 | SkipPathPrefixes: *skipPathPrefixes, 112 | }) 113 | 114 | case pull.FullCommand(): 115 | PullCommand(ui, PullCommandInput{ 116 | Dir: *pullDir, 117 | CanDelete: *pullCanDelete, 118 | HeuristicCfnMatching: !*lookupCfn, 119 | SkipTagged: *skipTagged, 120 | IncludeTagged: *includeTagged, 121 | SkipPathPrefixes: *skipPathPrefixes, 122 | }) 123 | 124 | case format.FullCommand(): 125 | FormatCommand(ui, FormatCommandInput{ 126 | Dir: *formatDir, 127 | CanDelete: *formatCanDelete, 128 | }) 129 | } 130 | } 131 | 132 | func init() { 133 | dir, err := os.Getwd() 134 | if err != nil { 135 | panic(err) 136 | } 137 | dir, err = filepath.EvalSymlinks(dir) 138 | if err != nil { 139 | panic(err) 140 | } 141 | defaultDir = filepath.Clean(dir) 142 | } 143 | -------------------------------------------------------------------------------- /iamy/cfn.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "time" 7 | 8 | "github.com/aws/aws-sdk-go/aws/awserr" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/aws/aws-sdk-go/service/cloudformation" 13 | "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface" 14 | ) 15 | 16 | var cfnResourceRegexp = regexp.MustCompile(`-[A-Z0-9]{10,20}$`) 17 | 18 | type CfnResourceType string 19 | 20 | const ( 21 | CfnIamPolicy = "AWS::IAM::Policy" 22 | CfnIamRole = "AWS::IAM::Role" 23 | CfnIamUser = "AWS::IAM::User" 24 | CfnIamGroup = "AWS::IAM::Group" 25 | CfnInstanceProfile = "AWS::IAM::InstanceProfile" 26 | CfnS3Bucket = "AWS::S3::Bucket" 27 | UpperCaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 28 | ) 29 | 30 | type cfnClient struct { 31 | cloudformationiface.CloudFormationAPI 32 | managedResources map[string]CfnResourceTypes 33 | } 34 | 35 | func newCfnClient(sess *session.Session) *cfnClient { 36 | return &cfnClient{ 37 | CloudFormationAPI: cloudformation.New(sess), 38 | } 39 | } 40 | 41 | // PopulateMangedResourceData enumerates all cloudformation stacks and resources to build an internal list of all 42 | // resources that are managed by cloudformation. This list can then be checked by IsManagedResource 43 | func (c *cfnClient) PopulateMangedResourceData() error { 44 | c.managedResources = map[string]CfnResourceTypes{} 45 | var nextStack *string 46 | 47 | for { 48 | stacks, err := c.ListStacks(&cloudformation.ListStacksInput{ 49 | NextToken: nextStack, 50 | StackStatusFilter: []*string{ 51 | aws.String("CREATE_IN_PROGRESS"), 52 | aws.String("CREATE_COMPLETE"), 53 | aws.String("ROLLBACK_COMPLETE"), 54 | aws.String("IMPORT_COMPLETE"), 55 | aws.String("REVIEW_IN_PROGRESS"), 56 | aws.String("CREATE_IN_PROGRESS"), 57 | aws.String("UPDATE_ROLLBACK_COMPLETE"), 58 | aws.String("UPDATE_IN_PROGRESS"), 59 | aws.String("UPDATE_COMPLETE_CLEANUP_IN_PROGRESS"), 60 | aws.String("UPDATE_COMPLETE"), 61 | aws.String("UPDATE_ROLLBACK_IN_PROGRESS"), 62 | aws.String("UPDATE_ROLLBACK_FAILED"), 63 | aws.String("UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS"), 64 | aws.String("UPDATE_ROLLBACK_COMPLETE"), 65 | aws.String("REVIEW_IN_PROGRESS"), 66 | }, 67 | }) 68 | if awserr, ok := err.(awserr.Error); ok && awserr != nil && awserr.Code() == "Throttling" { 69 | time.Sleep(1 * time.Second) 70 | continue 71 | } 72 | if err != nil { 73 | return err 74 | } 75 | 76 | for _, stack := range stacks.StackSummaries { 77 | var nextResource *string 78 | for { 79 | resources, err := c.ListStackResources(&cloudformation.ListStackResourcesInput{ 80 | NextToken: nextResource, 81 | StackName: stack.StackName, 82 | }) 83 | if awserr, ok := err.(awserr.Error); ok && awserr != nil && awserr.Code() == "Throttling" { 84 | time.Sleep(1 * time.Second) 85 | continue 86 | } 87 | if err != nil { 88 | return err 89 | } 90 | 91 | for _, resource := range resources.StackResourceSummaries { 92 | if resource.PhysicalResourceId == nil { 93 | continue 94 | } 95 | resType := CfnResourceType(*resource.ResourceType) 96 | if resType == "AWS::IAM::ManagedPolicy" { 97 | resType = CfnIamPolicy // we dont care about the distinction as they are both in the "policy" namespace 98 | } 99 | name := *resource.PhysicalResourceId 100 | // Dont know why, but some physical ids are arns, instead of names... 101 | if strings.HasPrefix(*resource.PhysicalResourceId, "arn:aws:iam") { 102 | parts := strings.Split(*resource.PhysicalResourceId, "/") 103 | name = parts[len(parts)-1] 104 | } 105 | 106 | if !resType.isInterestingResource() { 107 | continue 108 | } 109 | 110 | c.managedResources[name] = append(c.managedResources[name], resType) 111 | } 112 | 113 | nextResource = resources.NextToken 114 | if nextResource == nil { 115 | break 116 | } 117 | } 118 | } 119 | 120 | nextStack = stacks.NextToken 121 | if nextStack == nil { 122 | break 123 | } 124 | } 125 | 126 | return nil 127 | } 128 | 129 | // IsManagedResource checks if the given resource is managed by cloudformation 130 | // 131 | // If PopulateMangedResourceData has been called it will be accurate, however for some accounts this may be slow. 132 | // If PopulateMangedResourceData has not been called it will use a heuristic match, looking for the random ID that 133 | // CFN appends to the name 134 | func (c *cfnClient) IsManagedResource(cfnType CfnResourceType, resourceIdentifier string) bool { 135 | if c.managedResources != nil { 136 | return c.managedResources[resourceIdentifier].contains(cfnType) 137 | } 138 | parts := strings.Split(resourceIdentifier, "-") 139 | cfnIdentifier := parts[len(parts)-1] 140 | 141 | if cfnResourceRegexp.MatchString(resourceIdentifier) && strings.ContainsAny(cfnIdentifier, UpperCaseLetters) { 142 | return true 143 | } 144 | 145 | return false 146 | } 147 | 148 | func (r CfnResourceType) isInterestingResource() bool { 149 | switch r { 150 | case CfnIamPolicy, CfnIamRole, CfnIamUser, CfnIamGroup, CfnInstanceProfile, CfnS3Bucket: 151 | return true 152 | } 153 | 154 | return false 155 | } 156 | 157 | type CfnResourceTypes []CfnResourceType 158 | 159 | func (r CfnResourceTypes) contains(t CfnResourceType) bool { 160 | for _, v := range r { 161 | if v == t { 162 | return true 163 | } 164 | } 165 | return false 166 | } 167 | -------------------------------------------------------------------------------- /iamy/yaml.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "text/template" 11 | 12 | "github.com/ghodss/yaml" 13 | ) 14 | 15 | const pathTemplateBlob = "{{.Account}}/{{.Resource.Service}}/{{.Resource.ResourceType}}{{.Resource.ResourcePath}}{{.Resource.ResourceName}}.yaml" 16 | const pathRegexBlob = `^(?P[^/]+)/(?P(iam/instance-profile|iam/user|iam/group|iam/policy|iam/role|s3))(?P.*/)(?P[^/]+)\.yaml$` 17 | 18 | var pathTemplate = template.Must(template.New("").Parse(pathTemplateBlob)) 19 | var pathRegex = regexp.MustCompile(pathRegexBlob) 20 | 21 | type pathTemplateData struct { 22 | Account *Account 23 | Resource AwsResource 24 | } 25 | 26 | // A YamlLoadDumper loads and dumps account data in yaml files 27 | type YamlLoadDumper struct { 28 | Dir string 29 | } 30 | 31 | func (a *YamlLoadDumper) getFilesRecursively() ([]string, error) { 32 | paths := []string{} 33 | err := filepath.Walk(a.Dir, func(path string, info os.FileInfo, err error) error { 34 | if err != nil { 35 | return err 36 | } 37 | 38 | path, err = filepath.Rel(a.Dir, path) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if !info.IsDir() { 44 | paths = append(paths, filepath.ToSlash(path)) 45 | } 46 | 47 | return nil 48 | }) 49 | 50 | return paths, err 51 | } 52 | 53 | func namedMatch(r *regexp.Regexp, s string) (bool, map[string]string) { 54 | match := r.FindStringSubmatch(s) 55 | if len(match) == 0 { 56 | return false, nil 57 | } 58 | 59 | result := make(map[string]string) 60 | for i, name := range r.SubexpNames() { 61 | result[name] = match[i] 62 | } 63 | return true, result 64 | } 65 | 66 | // Load reads yaml files in a.Dir and returns the AccountData 67 | func (a *YamlLoadDumper) Load() ([]AccountData, error) { 68 | log.Println("Loading YAML IAM data from", a.Dir) 69 | accounts := map[string]*AccountData{} 70 | 71 | allFiles, err := a.getFilesRecursively() 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | for _, fp := range allFiles { 77 | if matched, result := namedMatch(pathRegex, fp); matched { 78 | log.Println("Loading", fp) 79 | 80 | accountid := result["account"] 81 | entity := result["entity"] 82 | path := result["resourcepath"] 83 | name := result["resourcename"] 84 | 85 | if _, ok := accounts[accountid]; !ok { 86 | accounts[accountid] = NewAccountData(accountid) 87 | } 88 | 89 | var err error 90 | nameAndPath := iamService{Name: name, Path: path} 91 | 92 | switch entity { 93 | case "iam/user": 94 | u := User{ 95 | iamService: nameAndPath, 96 | Tags: make(map[string]string), 97 | } 98 | err = a.unmarshalYamlFile(fp, &u) 99 | accounts[accountid].addUser(&u) 100 | case "iam/group": 101 | g := Group{iamService: nameAndPath} 102 | err = a.unmarshalYamlFile(fp, &g) 103 | accounts[accountid].addGroup(&g) 104 | case "iam/role": 105 | r := Role{iamService: nameAndPath} 106 | err = a.unmarshalYamlFile(fp, &r) 107 | accounts[accountid].addRole(&r) 108 | case "iam/policy": 109 | p := Policy{iamService: nameAndPath} 110 | err = a.unmarshalYamlFile(fp, &p) 111 | accounts[accountid].addPolicy(&p) 112 | case "iam/instance-profile": 113 | profile := InstanceProfile{iamService: nameAndPath} 114 | err = a.unmarshalYamlFile(fp, &profile) 115 | accounts[accountid].addInstanceProfile(&profile) 116 | case "s3": 117 | bp := BucketPolicy{BucketName: name} 118 | err = a.unmarshalYamlFile(fp, &bp) 119 | accounts[accountid].addBucketPolicy(&bp) 120 | default: 121 | panic("Unexpected entity") 122 | } 123 | 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | } else { 129 | log.Println("Skipping", fp) 130 | } 131 | } 132 | 133 | return accountMapToSlice(accounts), nil 134 | } 135 | 136 | func accountMapToSlice(accounts map[string]*AccountData) (aa []AccountData) { 137 | for _, a := range accounts { 138 | aa = append(aa, *a) 139 | } 140 | return 141 | } 142 | 143 | // Dump writes AccountData into yaml files in the a.Dir directory 144 | func (f *YamlLoadDumper) Dump(accountData *AccountData, canDelete bool) error { 145 | destDir := filepath.Join(f.Dir, accountData.Account.String()) 146 | log.Println("Dumping YAML IAM data to", f.Dir) 147 | 148 | if canDelete { 149 | if err := os.RemoveAll(destDir); err != nil { 150 | return err 151 | } 152 | } 153 | 154 | for _, u := range accountData.Users { 155 | if err := f.writeResource(accountData.Account, u); err != nil { 156 | return err 157 | } 158 | } 159 | 160 | for _, policy := range accountData.Policies { 161 | if err := f.writeResource(accountData.Account, policy); err != nil { 162 | return err 163 | } 164 | } 165 | 166 | for _, group := range accountData.Groups { 167 | if err := f.writeResource(accountData.Account, group); err != nil { 168 | return err 169 | } 170 | } 171 | 172 | for _, role := range accountData.Roles { 173 | if err := f.writeResource(accountData.Account, role); err != nil { 174 | return err 175 | } 176 | } 177 | 178 | for _, profile := range accountData.InstanceProfiles { 179 | if err := f.writeResource(accountData.Account, profile); err != nil { 180 | return err 181 | } 182 | } 183 | 184 | for _, bucketPolicy := range accountData.BucketPolicies { 185 | if err := f.writeResource(accountData.Account, bucketPolicy); err != nil { 186 | return err 187 | } 188 | } 189 | 190 | return nil 191 | } 192 | 193 | func (f *YamlLoadDumper) unmarshalYamlFile(relativePath string, entity interface{}) error { 194 | path := filepath.Join(f.Dir, relativePath) 195 | data, err := ioutil.ReadFile(path) 196 | if err != nil { 197 | return err 198 | } 199 | err = yaml.Unmarshal(data, entity) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | return nil 205 | } 206 | 207 | func (f *YamlLoadDumper) writeResource(a *Account, r AwsResource) error { 208 | path := mustExecutePathTemplate(pathTemplateData{a, r}) 209 | 210 | return writeYamlFile(filepath.Join(f.Dir, path), r) 211 | } 212 | 213 | func mustExecutePathTemplate(data interface{}) string { 214 | buf := &bytes.Buffer{} 215 | if err := pathTemplate.Execute(buf, data); err != nil { 216 | panic(err) 217 | } 218 | 219 | return buf.String() 220 | } 221 | 222 | func writeYamlFile(path string, thing interface{}) error { 223 | b, err := yaml.Marshal(thing) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | dir := filepath.Dir(path) 229 | 230 | if err = os.MkdirAll(dir, 0777); err != nil { 231 | return err 232 | } 233 | 234 | if err = ioutil.WriteFile(path, b, 0666); err != nil { 235 | return err 236 | } 237 | 238 | return nil 239 | } 240 | -------------------------------------------------------------------------------- /iamy/models.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | type Account struct { 10 | Id string 11 | Alias string 12 | } 13 | 14 | func (a Account) String() string { 15 | if a.Alias != "" { 16 | return fmt.Sprintf("%s-%s", a.Alias, a.Id) 17 | } 18 | return a.Id 19 | } 20 | 21 | var accountReg = regexp.MustCompile(`^(([\w-]+)-)?(\d+)$`) 22 | 23 | func NewAccountFromString(s string) *Account { 24 | acct := Account{} 25 | result := accountReg.FindAllStringSubmatch(s, -1) 26 | 27 | if len(result) == 0 { 28 | panic(fmt.Sprintf("Can't create account name from %s", s)) 29 | } else if len(result[0]) == 4 { 30 | acct.Alias = result[0][2] 31 | acct.Id = result[0][3] 32 | } else if len(result[0]) == 3 { 33 | acct.Id = result[0][1] 34 | } else { 35 | panic(fmt.Sprintf("Can't create account name from %s", s)) 36 | } 37 | 38 | return &acct 39 | } 40 | 41 | type AwsResource interface { 42 | Service() string 43 | ResourceType() string 44 | ResourceName() string 45 | ResourcePath() string 46 | } 47 | 48 | func Arn(r AwsResource, a *Account) string { 49 | return a.arnFor(r.ResourceType(), r.ResourcePath(), r.ResourceName()) 50 | } 51 | 52 | type iamService struct { 53 | Name string `json:"-"` 54 | Path string `json:"-"` 55 | } 56 | 57 | func (s iamService) Service() string { 58 | return "iam" 59 | } 60 | 61 | func (s iamService) ResourceName() string { 62 | return s.Name 63 | } 64 | 65 | func (s iamService) ResourcePath() string { 66 | return s.Path 67 | } 68 | 69 | type User struct { 70 | iamService `json:"-"` 71 | Groups []string `json:"Groups,omitempty"` 72 | InlinePolicies []InlinePolicy `json:"InlinePolicies,omitempty"` 73 | Policies []string `json:"Policies,omitempty"` 74 | Tags map[string]string `json:"Tags,omitempty"` 75 | } 76 | 77 | func (u User) ResourceType() string { 78 | return "user" 79 | } 80 | 81 | type Group struct { 82 | iamService `json:"-"` 83 | InlinePolicies []InlinePolicy `json:"InlinePolicies,omitempty"` 84 | Policies []string `json:"Policies,omitempty"` 85 | } 86 | 87 | func (g Group) ResourceType() string { 88 | return "group" 89 | } 90 | 91 | type InlinePolicy struct { 92 | Name string `json:"Name"` 93 | Policy *PolicyDocument `json:"Policy"` 94 | } 95 | 96 | type Policy struct { 97 | iamService `json:"-"` 98 | numberOfVersions int 99 | oldestVersionId string 100 | nondefaultVersionIds []string 101 | Description string `json:"Description,omitempty"` 102 | Policy *PolicyDocument `json:"Policy"` 103 | Tags map[string]string `json:"Tags,omitempty"` 104 | } 105 | 106 | func (p Policy) ResourceType() string { 107 | return "policy" 108 | } 109 | 110 | type Role struct { 111 | iamService `json:"-"` 112 | Description string `json:"Description,omitempty"` 113 | AssumeRolePolicyDocument *PolicyDocument `json:"AssumeRolePolicyDocument"` 114 | InlinePolicies []InlinePolicy `json:"InlinePolicies,omitempty"` 115 | Policies []string `json:"Policies,omitempty"` 116 | MaxSessionDuration int `json:"MaxSessionDuration,omitempty"` 117 | } 118 | 119 | type InstanceProfile struct { 120 | iamService `json:"-"` 121 | Roles []string `json:"Roles,omitempty"` 122 | Tags map[string]string `json:"Tags,omitempty"` 123 | } 124 | 125 | func (ip InstanceProfile) ResourceType() string { 126 | return "instance-profile" 127 | } 128 | 129 | func (r Role) ResourceType() string { 130 | return "role" 131 | } 132 | 133 | type BucketPolicy struct { 134 | BucketName string `json:"-"` 135 | Policy *PolicyDocument `json:"Policy"` 136 | } 137 | 138 | func (bp BucketPolicy) Service() string { 139 | return "s3" 140 | } 141 | 142 | func (bp BucketPolicy) ResourceType() string { 143 | return "" 144 | } 145 | 146 | func (bp BucketPolicy) ResourceName() string { 147 | return bp.BucketName 148 | } 149 | 150 | func (bp BucketPolicy) ResourcePath() string { 151 | return "/" 152 | } 153 | 154 | type AccountData struct { 155 | Account *Account 156 | Users []*User 157 | Groups []*Group 158 | Roles []*Role 159 | Policies []*Policy 160 | BucketPolicies []*BucketPolicy 161 | InstanceProfiles []*InstanceProfile 162 | } 163 | 164 | func NewAccountData(account string) *AccountData { 165 | return &AccountData{ 166 | Account: NewAccountFromString(account), 167 | Users: []*User{}, 168 | Groups: []*Group{}, 169 | Roles: []*Role{}, 170 | Policies: []*Policy{}, 171 | InstanceProfiles: []*InstanceProfile{}, 172 | } 173 | } 174 | 175 | func (a *AccountData) addUser(u *User) { 176 | a.Users = append(a.Users, u) 177 | } 178 | 179 | func (a *AccountData) addGroup(g *Group) { 180 | a.Groups = append(a.Groups, g) 181 | } 182 | 183 | func (a *AccountData) addRole(r *Role) { 184 | a.Roles = append(a.Roles, r) 185 | } 186 | 187 | func (a *AccountData) addPolicy(p *Policy) { 188 | a.Policies = append(a.Policies, p) 189 | } 190 | 191 | func (a *AccountData) addInstanceProfile(p *InstanceProfile) { 192 | a.InstanceProfiles = append(a.InstanceProfiles, p) 193 | } 194 | 195 | func (a *AccountData) addBucketPolicy(bp *BucketPolicy) { 196 | a.BucketPolicies = append(a.BucketPolicies, bp) 197 | } 198 | 199 | func (a *AccountData) FindUserByName(name, path string) (bool, *User) { 200 | for _, u := range a.Users { 201 | if u.Name == name && u.Path == path { 202 | return true, u 203 | } 204 | } 205 | 206 | return false, nil 207 | } 208 | 209 | func (a *AccountData) FindGroupByName(name, path string) (bool, *Group) { 210 | for _, g := range a.Groups { 211 | if g.Name == name && g.Path == path { 212 | return true, g 213 | } 214 | } 215 | 216 | return false, nil 217 | } 218 | 219 | func (a *AccountData) FindRoleByName(name, path string) (bool, *Role) { 220 | for _, r := range a.Roles { 221 | if r.Name == name && r.Path == path { 222 | return true, r 223 | } 224 | } 225 | 226 | return false, nil 227 | } 228 | 229 | func (a *AccountData) FindPolicyByName(name, path string) (bool, *Policy) { 230 | for _, p := range a.Policies { 231 | if p.Name == name && p.Path == path { 232 | return true, p 233 | } 234 | } 235 | 236 | return false, nil 237 | } 238 | 239 | func (a *AccountData) FindInstanceProfileByName(name, path string) (bool, *InstanceProfile) { 240 | for _, p := range a.InstanceProfiles { 241 | if p.Name == name && p.Path == path { 242 | return true, p 243 | } 244 | } 245 | 246 | return false, nil 247 | } 248 | 249 | func (a *AccountData) FindBucketPolicyByBucketName(name string) (bool, *BucketPolicy) { 250 | for _, p := range a.BucketPolicies { 251 | if p.BucketName == name { 252 | return true, p 253 | } 254 | } 255 | 256 | return false, nil 257 | } 258 | 259 | func (a *Account) arnFor(key, path, name string) string { 260 | return fmt.Sprintf("arn:aws:iam::%s:%s%s%s", a.Id, key, path, name) 261 | } 262 | 263 | func (a *Account) policyArnFromString(nameOrArn string) string { 264 | if strings.HasPrefix(nameOrArn, "arn:") { 265 | return nameOrArn 266 | } 267 | 268 | return fmt.Sprintf("arn:aws:iam::%s:policy/%s", a.Id, nameOrArn) 269 | } 270 | 271 | func (a *Account) normalisePolicyArn(arn string) string { 272 | return strings.TrimPrefix(arn, fmt.Sprintf("arn:aws:iam::%s:policy/", a.Id)) 273 | } 274 | -------------------------------------------------------------------------------- /iamy/aws_test.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/aws/aws-sdk-go/service/iam" 7 | ) 8 | 9 | const cloudformationStackNameTag = "aws:cloudformation:stack-name" 10 | const includeTestTag = "iamy-include" 11 | const testSkipPathPrefix = "/aws-reserved/" 12 | 13 | func TestIsSkippableManagedResource(t *testing.T) { 14 | skippables := []string{ 15 | "myalias-123/iam/role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot.yaml", 16 | "AWSServiceRoleTest", 17 | "my-example-role-ABCDEFGH1234567", 18 | } 19 | 20 | nonSkippables := []string{ 21 | "myalias-123/iam/user/foo/billy.blogs.yaml", 22 | "myalias-123/s3/my-bucket.yaml", 23 | "myalias-123/iam/instance-profile/example.yaml", 24 | } 25 | 26 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{testSkipPathPrefix}} 27 | 28 | for _, name := range skippables { 29 | t.Run(name, func(t *testing.T) { 30 | 31 | skipped, err := f.isSkippableManagedResource(CfnIamRole, name, map[string]string{}, "/") 32 | if skipped == false { 33 | t.Errorf("expected %s to be skipped but got false", name) 34 | } 35 | 36 | if err == "" { 37 | t.Errorf("expected %s to output an error message but it was empty", name) 38 | } 39 | }) 40 | } 41 | 42 | for _, name := range nonSkippables { 43 | t.Run(name, func(t *testing.T) { 44 | 45 | skipped, err := f.isSkippableManagedResource(CfnIamRole, name, map[string]string{}, "/") 46 | if skipped == true { 47 | t.Errorf("expected %s to not be skipped but got true", name) 48 | } 49 | 50 | if err != "" { 51 | t.Errorf("expected %s to not output an error message but got: %s", name, err) 52 | } 53 | }) 54 | } 55 | for _, name := range nonSkippables { 56 | t.Run(name, func(t *testing.T) { 57 | 58 | skipped, err := f.isSkippableManagedResource(CfnIamRole, name, map[string]string{}, testSkipPathPrefix) 59 | if skipped == false { 60 | t.Errorf("expected %s to be skipped due to path but got false", name) 61 | } 62 | 63 | if err == "" { 64 | t.Errorf("expected %s to output an error message but it was empty", name) 65 | } 66 | }) 67 | } 68 | } 69 | 70 | func TestSkippableS3TaggedResources(t *testing.T) { 71 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 72 | skippableTags := map[string]string{cloudformationStackNameTag: "my-stack"} 73 | 74 | skipped, err := f.isSkippableManagedResource(CfnS3Bucket, "my-bucket", skippableTags, "NOSKIP") 75 | if err == "" { 76 | t.Errorf("expected an error message but it was empty") 77 | } 78 | if skipped == false { 79 | t.Errorf("expected resource to be skipped but got false") 80 | } 81 | } 82 | 83 | func TestSkippableS3TaggedResources_WithNoSkipTags(t *testing.T) { 84 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 85 | skippableTags := map[string]string{cloudformationStackNameTag: "my-stack"} 86 | 87 | skipped, err := f.isSkippableManagedResource(CfnS3Bucket, "my-bucket", skippableTags, "NOSKIP") 88 | if err != "" { 89 | t.Errorf("expected no error message but it was " + err) 90 | } 91 | if skipped == true { 92 | t.Errorf("expected resource to not be skipped but got true") 93 | } 94 | } 95 | 96 | func TestNonSkippableTaggedResources(t *testing.T) { 97 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 98 | nonSkippableTags := map[string]string{"Name": "blah"} 99 | 100 | skipped, err := f.isSkippableManagedResource(CfnS3Bucket, "my-bucket", nonSkippableTags, "NOSKIP") 101 | if err != "" { 102 | t.Errorf("expected no error message but got: %s", err) 103 | } 104 | if skipped == true { 105 | t.Errorf("expected resource to not be skipped but got true") 106 | } 107 | } 108 | 109 | func TestIncludeTagsOverrideSkip(t *testing.T) { 110 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 111 | TestTags := map[string]string{includeTestTag: "true"} 112 | 113 | skipped, err := f.isSkippableManagedResource(CfnS3Bucket, "my-bucket", TestTags, "NOSKIP") 114 | if err != "" { 115 | t.Errorf("expected no error message but got: %s", err) 116 | } 117 | if skipped == true { 118 | t.Errorf("expected resource to not be skipped but got true") 119 | } 120 | 121 | skipped, err = f.isSkippableManagedResource(CfnS3Bucket, "cfn-bucket-ABCDEF123456", TestTags, "NOSKIP") 122 | if err != "" { 123 | t.Errorf("expected no error message but got: %s", err) 124 | } 125 | if skipped == true { 126 | t.Errorf("expected resource to not be skipped but got true") 127 | } 128 | } 129 | 130 | func TestSkippableIAMUserResource(t *testing.T) { 131 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 132 | key := cloudformationStackNameTag 133 | val := "my-stack" 134 | userName := "my-user" 135 | path := "/" 136 | userList := []*iam.UserDetail{ 137 | {Tags: []*iam.Tag{{Key: &key, Value: &val}}, UserName: &userName, Path: &path}, 138 | } 139 | 140 | resp := iam.GetAccountAuthorizationDetailsOutput{UserDetailList: userList} 141 | f.populateIamData(&resp) 142 | for _, user := range f.data.Users { 143 | if user.Name == userName { 144 | t.Error("Expected to skip user with CFN tags") 145 | } 146 | } 147 | } 148 | 149 | func TestSkippableIAMUserResource_WithNoSkipTags(t *testing.T) { 150 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 151 | key := cloudformationStackNameTag 152 | val := "my-stack" 153 | userName := "my-user" 154 | path := "/" 155 | userList := []*iam.UserDetail{ 156 | {Tags: []*iam.Tag{{Key: &key, Value: &val}}, UserName: &userName, Path: &path}, 157 | } 158 | 159 | resp := iam.GetAccountAuthorizationDetailsOutput{UserDetailList: userList} 160 | f.populateIamData(&resp) 161 | foundUser := false 162 | for _, user := range f.data.Users { 163 | if user.Name == userName { 164 | foundUser = true 165 | } 166 | } 167 | 168 | if !foundUser { 169 | t.Error("Expected to not skip user with CFN tags when SkipTagged: []string{}") 170 | } 171 | } 172 | 173 | func TestSkippableIAMRoleResource(t *testing.T) { 174 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{cloudformationStackNameTag}, IncludeTagged: []string{includeTestTag}, SkipPathPrefixes: []string{}} 175 | key := cloudformationStackNameTag 176 | val := "my-stack" 177 | roleName := "my-role" 178 | path := "/" 179 | roleList := []*iam.RoleDetail{ 180 | {Tags: []*iam.Tag{{Key: &key, Value: &val}}, RoleName: &roleName, Path: &path}, 181 | } 182 | 183 | resp := iam.GetAccountAuthorizationDetailsOutput{RoleDetailList: roleList} 184 | f.populateIamData(&resp) 185 | for _, role := range f.data.Roles { 186 | if role.Name == roleName { 187 | t.Error("Expected to skip role with CFN tags") 188 | } 189 | } 190 | } 191 | 192 | func TestSkippableIAMRoleResource_WithNoSkipTags(t *testing.T) { 193 | f := AwsFetcher{cfn: &cfnClient{}, SkipTagged: []string{}, IncludeTagged: []string{includeTestTag}, SkipFetchingPolicyAndRoleDescriptions: true, SkipPathPrefixes: []string{}} 194 | key := cloudformationStackNameTag 195 | val := "my-stack" 196 | roleName := "my-role" 197 | path := "/" 198 | str := "{}" 199 | roleList := []*iam.RoleDetail{ 200 | {Tags: []*iam.Tag{{Key: &key, Value: &val}}, RoleName: &roleName, Path: &path, AssumeRolePolicyDocument: &str}, 201 | } 202 | 203 | resp := iam.GetAccountAuthorizationDetailsOutput{RoleDetailList: roleList} 204 | f.populateIamData(&resp) 205 | foundRole := false 206 | for _, role := range f.data.Roles { 207 | if role.Name == roleName { 208 | foundRole = true 209 | } 210 | } 211 | if !foundRole { 212 | t.Error("Expected to not skip role with CFN tags and SkipTagged: []string{}") 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /iamy/aws.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sort" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/service/iam" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | // AwsFetcher fetches account data from AWS 16 | type AwsFetcher struct { 17 | // As Policy and Role descriptions are immutable, we can skip fetching them 18 | // when pushing to AWS 19 | SkipFetchingPolicyAndRoleDescriptions bool 20 | HeuristicCfnMatching bool 21 | SkipTagged []string 22 | IncludeTagged []string 23 | SkipPathPrefixes []string 24 | 25 | Debug *log.Logger 26 | 27 | iam *iamClient 28 | s3 *s3Client 29 | cfn *cfnClient 30 | tagging *resourceGroupsTaggingAPIClient 31 | account *Account 32 | data AccountData 33 | 34 | descriptionFetchWaitGroup sync.WaitGroup 35 | descriptionFetchError error 36 | policyTagFetchError error 37 | } 38 | 39 | func (a *AwsFetcher) init() error { 40 | var err error 41 | 42 | s := awsSession() 43 | a.iam = newIamClient(s) 44 | a.s3 = newS3Client(s) 45 | a.cfn = newCfnClient(s) 46 | a.tagging = newResourceGroupsTaggingAPIClient(s) 47 | 48 | if a.account, err = a.getAccount(); err != nil { 49 | return err 50 | } 51 | a.data = AccountData{ 52 | Account: a.account, 53 | } 54 | 55 | return nil 56 | } 57 | 58 | // Fetch queries AWS for account data 59 | func (a *AwsFetcher) Fetch() (*AccountData, error) { 60 | if err := a.init(); err != nil { 61 | return nil, errors.Wrap(err, "Error in init") 62 | } 63 | 64 | if !a.HeuristicCfnMatching { 65 | log.Println("Fetching CFN data") 66 | if err := a.cfn.PopulateMangedResourceData(); err != nil { 67 | return nil, errors.Wrap(err, "Error fetching CFN data") 68 | } 69 | } 70 | 71 | var wg sync.WaitGroup 72 | var iamErr, s3Err error 73 | 74 | log.Println("Fetching IAM data") 75 | wg.Add(1) 76 | go func() { 77 | defer wg.Done() 78 | iamErr = a.fetchIamData() 79 | }() 80 | 81 | log.Println("Fetching S3 data") 82 | wg.Add(1) 83 | go func() { 84 | defer wg.Done() 85 | s3Err = a.fetchS3Data() 86 | }() 87 | 88 | wg.Wait() 89 | 90 | if iamErr != nil { 91 | return nil, errors.Wrap(iamErr, "Error fetching IAM data") 92 | } 93 | if s3Err != nil { 94 | return nil, errors.Wrap(s3Err, "Error fetching S3 data") 95 | } 96 | 97 | return &a.data, nil 98 | } 99 | 100 | func (a *AwsFetcher) fetchS3Data() error { 101 | buckets, err := a.s3.listAllBuckets() 102 | if err != nil { 103 | return errors.Wrap(err, "Error listing buckets") 104 | } 105 | for _, b := range buckets { 106 | if b.policyJson == "" { 107 | continue 108 | } 109 | if ok, err := a.isSkippableManagedResource(CfnS3Bucket, b.name, b.tags, "__DONTSKIPS3__"); ok { 110 | log.Printf(err) 111 | continue 112 | } 113 | 114 | policyDoc, err := NewPolicyDocumentFromJson(b.policyJson) 115 | if err != nil { 116 | return errors.Wrap(err, "Error creating Policy document") 117 | } 118 | 119 | bp := BucketPolicy{ 120 | BucketName: b.name, 121 | Policy: policyDoc, 122 | } 123 | 124 | a.data.BucketPolicies = append(a.data.BucketPolicies, &bp) 125 | } 126 | 127 | return nil 128 | } 129 | 130 | func (a *AwsFetcher) fetchIamData() error { 131 | var populateIamDataErr error 132 | var populateInstanceProfileErr error 133 | err := a.iam.GetAccountAuthorizationDetailsPages( 134 | &iam.GetAccountAuthorizationDetailsInput{ 135 | Filter: aws.StringSlice([]string{ 136 | iam.EntityTypeUser, 137 | iam.EntityTypeGroup, 138 | iam.EntityTypeRole, 139 | iam.EntityTypeLocalManagedPolicy, 140 | }), 141 | }, 142 | func(resp *iam.GetAccountAuthorizationDetailsOutput, lastPage bool) bool { 143 | populateIamDataErr = a.populateIamData(resp) 144 | if populateIamDataErr != nil { 145 | return false 146 | } 147 | return true 148 | }, 149 | ) 150 | if populateIamDataErr != nil { 151 | return err 152 | } 153 | if err != nil { 154 | return err 155 | } 156 | // Fetch instance profiles 157 | err = a.iam.ListInstanceProfilesPages(&iam.ListInstanceProfilesInput{}, 158 | func(resp *iam.ListInstanceProfilesOutput, lastPage bool) bool { 159 | populateInstanceProfileErr = a.populateInstanceProfileData(resp) 160 | if populateInstanceProfileErr != nil { 161 | return false 162 | } 163 | return true 164 | }) 165 | if populateInstanceProfileErr != nil { 166 | return err 167 | } 168 | if err != nil { 169 | return err 170 | } 171 | return nil 172 | } 173 | 174 | func (a *AwsFetcher) populateInlinePolicies(source []*iam.PolicyDetail, target *[]InlinePolicy) error { 175 | for _, ip := range source { 176 | doc, err := NewPolicyDocumentFromEncodedJson(*ip.PolicyDocument) 177 | if err != nil { 178 | return err 179 | } 180 | *target = append(*target, InlinePolicy{ 181 | Name: *ip.PolicyName, 182 | Policy: doc, 183 | }) 184 | } 185 | 186 | return nil 187 | } 188 | 189 | func (a *AwsFetcher) marshalPolicyDescriptionAsync(policyArn string, target *string) { 190 | a.descriptionFetchWaitGroup.Add(1) 191 | go func() { 192 | defer a.descriptionFetchWaitGroup.Done() 193 | log.Println("Fetching policy description for", policyArn) 194 | 195 | var err error 196 | *target, err = a.iam.getPolicyDescription(policyArn) 197 | if err != nil { 198 | a.descriptionFetchError = err 199 | } 200 | }() 201 | } 202 | 203 | func (a *AwsFetcher) fetchInstanceProfileTags(instanceProfileName string, tags *map[string]string) { 204 | log.Println("Fetching tags for instance profile ", instanceProfileName) 205 | 206 | var err error 207 | *tags, err = a.iam.getInstanceProfileTags(instanceProfileName) 208 | if err != nil { 209 | a.policyTagFetchError = err 210 | } 211 | } 212 | 213 | func (a *AwsFetcher) marshalRoleAsync(roleName string, roleDescription *string, roleMaxSessionDuration *int) { 214 | a.descriptionFetchWaitGroup.Add(1) 215 | go func() { 216 | defer a.descriptionFetchWaitGroup.Done() 217 | log.Println("Fetching role description for", roleName) 218 | 219 | var err error 220 | var sessionDuration int 221 | *roleDescription, sessionDuration, err = a.iam.getRole(roleName) 222 | if sessionDuration > 0 { 223 | *roleMaxSessionDuration = sessionDuration 224 | } 225 | if err != nil { 226 | a.descriptionFetchError = err 227 | } 228 | }() 229 | } 230 | 231 | func (a *AwsFetcher) populateInstanceProfileData(resp *iam.ListInstanceProfilesOutput) error { 232 | for _, profileResp := range resp.InstanceProfiles { 233 | tags := make(map[string]string) 234 | a.fetchInstanceProfileTags(*profileResp.InstanceProfileName, &tags) 235 | 236 | if ok, err := a.isSkippableManagedResource(CfnInstanceProfile, *profileResp.InstanceProfileName, tags, *profileResp.Path); ok { 237 | log.Printf(err) 238 | continue 239 | } 240 | 241 | profile := InstanceProfile{iamService: iamService{ 242 | Name: *profileResp.InstanceProfileName, 243 | Path: *profileResp.Path, 244 | }} 245 | for _, roleResp := range profileResp.Roles { 246 | role := *(roleResp.RoleName) 247 | profile.Roles = append(profile.Roles, role) 248 | } 249 | profile.Tags = tags 250 | a.data.InstanceProfiles = append(a.data.InstanceProfiles, &profile) 251 | } 252 | return nil 253 | } 254 | 255 | func (a *AwsFetcher) populateIamData(resp *iam.GetAccountAuthorizationDetailsOutput) error { 256 | for _, userResp := range resp.UserDetailList { 257 | tags := make(map[string]string) 258 | for _, tag := range userResp.Tags { 259 | tags[*tag.Key] = *tag.Value 260 | } 261 | 262 | if ok, err := a.isSkippableManagedResource(CfnIamUser, *userResp.UserName, tags, *userResp.Path); ok { 263 | log.Printf(err) 264 | continue 265 | } 266 | 267 | user := User{ 268 | iamService: iamService{ 269 | Name: *userResp.UserName, 270 | Path: *userResp.Path, 271 | }, 272 | } 273 | 274 | for _, g := range userResp.GroupList { 275 | user.Groups = append(user.Groups, *g) 276 | } 277 | sort.SliceStable(user.Groups, func(i, j int) bool { 278 | return user.Groups[i] < user.Groups[j] 279 | }) 280 | for _, p := range userResp.AttachedManagedPolicies { 281 | user.Policies = append(user.Policies, a.account.normalisePolicyArn(*p.PolicyArn)) 282 | } 283 | sort.SliceStable(user.Policies, func(i, j int) bool { 284 | return user.Policies[i] < user.Policies[j] 285 | }) 286 | if err := a.populateInlinePolicies(userResp.UserPolicyList, &user.InlinePolicies); err != nil { 287 | return err 288 | } 289 | user.Tags = tags 290 | 291 | a.data.Users = append(a.data.Users, &user) 292 | } 293 | 294 | for _, groupResp := range resp.GroupDetailList { 295 | if ok, err := a.isSkippableManagedResource(CfnIamGroup, *groupResp.GroupName, map[string]string{}, *groupResp.Path); ok { 296 | log.Printf(err) 297 | continue 298 | } 299 | 300 | group := Group{iamService: iamService{ 301 | Name: *groupResp.GroupName, 302 | Path: *groupResp.Path, 303 | }} 304 | 305 | for _, p := range groupResp.AttachedManagedPolicies { 306 | group.Policies = append(group.Policies, a.account.normalisePolicyArn(*p.PolicyArn)) 307 | } 308 | sort.SliceStable(group.Policies, func(i, j int) bool { 309 | return group.Policies[i] < group.Policies[j] 310 | }) 311 | if err := a.populateInlinePolicies(groupResp.GroupPolicyList, &group.InlinePolicies); err != nil { 312 | return err 313 | } 314 | 315 | a.data.Groups = append(a.data.Groups, &group) 316 | } 317 | 318 | for _, roleResp := range resp.RoleDetailList { 319 | tags := make(map[string]string) 320 | for _, tag := range roleResp.Tags { 321 | tags[*tag.Key] = *tag.Value 322 | } 323 | 324 | if ok, err := a.isSkippableManagedResource(CfnIamRole, *roleResp.RoleName, tags, *roleResp.Path); ok { 325 | log.Printf(err) 326 | continue 327 | } 328 | 329 | role := Role{iamService: iamService{ 330 | Name: *roleResp.RoleName, 331 | Path: *roleResp.Path, 332 | }} 333 | 334 | if !a.SkipFetchingPolicyAndRoleDescriptions { 335 | a.marshalRoleAsync(*roleResp.RoleName, &role.Description, &role.MaxSessionDuration) 336 | } 337 | 338 | var err error 339 | role.AssumeRolePolicyDocument, err = NewPolicyDocumentFromEncodedJson(*roleResp.AssumeRolePolicyDocument) 340 | if err != nil { 341 | return err 342 | } 343 | for _, p := range roleResp.AttachedManagedPolicies { 344 | role.Policies = append(role.Policies, a.account.normalisePolicyArn(*p.PolicyArn)) 345 | } 346 | sort.SliceStable(role.Policies, func(i, j int) bool { 347 | return role.Policies[i] < role.Policies[j] 348 | }) 349 | if err := a.populateInlinePolicies(roleResp.RolePolicyList, &role.InlinePolicies); err != nil { 350 | return err 351 | } 352 | 353 | a.data.addRole(&role) 354 | } 355 | 356 | policyArns := make([]*string, 0) 357 | for _, policyResp := range resp.Policies { 358 | policyArns = append(policyArns, policyResp.Arn) 359 | } 360 | 361 | policyTags, err := a.tagging.getMultiplePolicyTags(policyArns) 362 | if err != nil { 363 | log.Printf("Error: %v", err) 364 | return err 365 | } 366 | 367 | for _, policyResp := range resp.Policies { 368 | if ok, err := a.isSkippableManagedResource(CfnIamPolicy, *policyResp.PolicyName, map[string]string{}, *policyResp.Path); ok { 369 | log.Printf(err) 370 | continue 371 | } 372 | 373 | defaultPolicyVersion := findDefaultPolicyVersion(policyResp.PolicyVersionList) 374 | doc, err := NewPolicyDocumentFromEncodedJson(*defaultPolicyVersion.Document) 375 | if err != nil { 376 | return err 377 | } 378 | 379 | p := Policy{ 380 | iamService: iamService{ 381 | Name: *policyResp.PolicyName, 382 | Path: *policyResp.Path, 383 | }, 384 | oldestVersionId: findOldestPolicyVersionId(policyResp.PolicyVersionList), 385 | numberOfVersions: len(policyResp.PolicyVersionList), 386 | nondefaultVersionIds: findNonDefaultPolicyVersionIds(policyResp.PolicyVersionList), 387 | Policy: doc, 388 | } 389 | 390 | if !a.SkipFetchingPolicyAndRoleDescriptions { 391 | a.marshalPolicyDescriptionAsync(*policyResp.Arn, &p.Description) 392 | } 393 | if tags, ok := policyTags[*policyResp.Arn]; ok { 394 | p.Tags = tags 395 | } else { 396 | p.Tags = make(map[string]string) 397 | } 398 | // Need to do this _after_ we fetch tags for the Policy 399 | if ok, err := a.isSkippableManagedResource(CfnIamPolicy, *policyResp.PolicyName, p.Tags, *policyResp.Path); ok { 400 | log.Printf(err) 401 | continue 402 | } 403 | 404 | a.data.addPolicy(&p) 405 | } 406 | 407 | a.descriptionFetchWaitGroup.Wait() 408 | 409 | return a.descriptionFetchError 410 | } 411 | 412 | func findDefaultPolicyVersion(versions []*iam.PolicyVersion) *iam.PolicyVersion { 413 | for _, version := range versions { 414 | if *version.IsDefaultVersion { 415 | return version 416 | } 417 | } 418 | panic("Expected a default policy version") 419 | } 420 | 421 | func findNonDefaultPolicyVersionIds(versions []*iam.PolicyVersion) []string { 422 | ss := []string{} 423 | for _, version := range versions { 424 | if !*version.IsDefaultVersion { 425 | ss = append(ss, *version.VersionId) 426 | } 427 | } 428 | return ss 429 | } 430 | 431 | func findOldestPolicyVersionId(versions []*iam.PolicyVersion) string { 432 | oldest := versions[0] 433 | for _, version := range versions[1:] { 434 | if version.CreateDate.Before(*oldest.CreateDate) { 435 | oldest = version 436 | } 437 | } 438 | return *oldest.VersionId 439 | } 440 | 441 | func (a *AwsFetcher) getAccount() (*Account, error) { 442 | var err error 443 | acct := Account{} 444 | 445 | acct.Id, err = GetAwsAccountId(awsSession(), a.Debug) 446 | if err == aws.ErrMissingRegion { 447 | return nil, errors.New("Error determining the AWS account id - check the AWS_REGION environment variable is set") 448 | } 449 | if err != nil { 450 | return nil, err 451 | } 452 | 453 | aliasResp, err := a.iam.ListAccountAliases(&iam.ListAccountAliasesInput{}) 454 | if err != nil { 455 | return nil, err 456 | } 457 | if len(aliasResp.AccountAliases) > 0 { 458 | acct.Alias = *aliasResp.AccountAliases[0] 459 | } 460 | 461 | return &acct, nil 462 | } 463 | 464 | // isSkippableResource takes the resource identifier as a string and 465 | // checks it against known resources that we shouldn't need to manage as 466 | // it will already be managed by another process (such as Cloudformation 467 | // roles). 468 | // 469 | // Returns a boolean of whether it can be skipped and a string of the 470 | // reasoning why it was skipped. 471 | 472 | func (a *AwsFetcher) isSkippableManagedResource(cfnType CfnResourceType, resourceIdentifier string, tags map[string]string, resourcePath string) (bool, string) { 473 | if len(a.IncludeTagged) > 0 { 474 | for _, tag := range a.IncludeTagged { 475 | if _, ok := tags[tag]; ok { 476 | return false, "" 477 | } 478 | } 479 | } 480 | 481 | for _, tag := range a.SkipTagged { 482 | if stackName, ok := tags[tag]; ok { 483 | return true, fmt.Sprintf("Skipping resource %s tagged with %s in stack %s", resourceIdentifier, tag, stackName) 484 | } 485 | } 486 | 487 | for _, path := range a.SkipPathPrefixes { 488 | if strings.HasPrefix(resourcePath, path) { 489 | return true, fmt.Sprintf("Skipping resource %s with path %s matches %s", resourceIdentifier, resourcePath, path) 490 | } 491 | } 492 | 493 | if a.cfn.IsManagedResource(cfnType, resourceIdentifier) { 494 | return true, fmt.Sprintf("CloudFormation generated resource %s", resourceIdentifier) 495 | } 496 | 497 | if strings.Contains(resourceIdentifier, "AWSServiceRole") || strings.Contains(resourceIdentifier, "aws-service-role") { 498 | return true, fmt.Sprintf("AWS Service role generated resource %s", resourceIdentifier) 499 | } 500 | 501 | return false, "" 502 | } 503 | -------------------------------------------------------------------------------- /iamy/awsdiff.go: -------------------------------------------------------------------------------- 1 | package iamy 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // MaxAllowedPolicyVersions are the number of Versions of a managed policy that can be stored 11 | // See http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-limits.html 12 | const MaxAllowedPolicyVersions = 5 13 | 14 | type Cmd struct { 15 | Name string 16 | Args []string 17 | } 18 | 19 | func (c Cmd) String() string { 20 | parts := []string{c.Name} 21 | 22 | for _, a := range c.Args { 23 | if strings.ContainsAny(a, " ") { 24 | // naive quoting to shell argument 25 | a = fmt.Sprintf("'%s'", a) 26 | } 27 | 28 | parts = append(parts, a) 29 | } 30 | 31 | return strings.Join(parts, " ") 32 | } 33 | 34 | // IsDestructive indicates if the aws command is destructive 35 | func (c Cmd) IsDestructive() bool { 36 | if len(c.Args) >= 2 { 37 | a := c.Args[1] 38 | if strings.HasPrefix(a, "de") || strings.HasPrefix(a, "remove") { 39 | return true 40 | } 41 | } 42 | return false 43 | } 44 | 45 | type CmdList []Cmd 46 | 47 | func (cc *CmdList) Add(name string, args ...string) { 48 | *cc = append(*cc, Cmd{name, args}) 49 | } 50 | 51 | func (cc CmdList) String() string { 52 | parts := []string{} 53 | for _, c := range cc { 54 | parts = append(parts, c.String()) 55 | } 56 | 57 | return strings.Join(parts, "\n") 58 | } 59 | 60 | func (cc CmdList) Count() int { 61 | return len(cc) 62 | } 63 | 64 | func (cc CmdList) CountDestructive() int { 65 | count := 0 66 | for _, c := range cc { 67 | if c.IsDestructive() { 68 | count++ 69 | } 70 | } 71 | return count 72 | } 73 | 74 | func path(v string) string { 75 | if v == "" { 76 | return "/" 77 | } 78 | 79 | return v 80 | } 81 | 82 | func mapTagsToString(tags map[string]string) string { 83 | var result []string 84 | for k, v := range tags { 85 | result = append(result, "Key="+k+",Value="+v) 86 | } 87 | return strings.Join(result, ",") 88 | } 89 | 90 | type awsSyncCmdGenerator struct { 91 | from, to *AccountData 92 | cmds CmdList 93 | } 94 | 95 | func (a *awsSyncCmdGenerator) deleteOldEntities() { 96 | iam := newIamClient(awsSession()) 97 | 98 | for _, fromInstanceProfile := range a.from.InstanceProfiles { 99 | if found, _ := a.to.FindInstanceProfileByName(fromInstanceProfile.Name, fromInstanceProfile.Path); !found { 100 | for _, roleName := range fromInstanceProfile.Roles { 101 | a.cmds.Add("aws", "iam", "remove-role-from-instance-profile", "--instance-profile-name", fromInstanceProfile.Name, "--role-name", roleName) 102 | } 103 | a.cmds.Add("aws", "iam", "delete-instance-profile", 104 | "--instance-profile-name", fromInstanceProfile.Name) 105 | } 106 | } 107 | for _, fromRole := range a.from.Roles { 108 | if found, _ := a.to.FindRoleByName(fromRole.Name, fromRole.Path); !found { 109 | // detach managed policies 110 | for _, p := range fromRole.Policies { 111 | a.cmds.Add("aws", "iam", "detach-role-policy", 112 | "--role-name", fromRole.Name, 113 | "--policy-arn", a.to.Account.policyArnFromString(p)) 114 | } 115 | // remove inline policies 116 | for _, ip := range fromRole.InlinePolicies { 117 | a.cmds.Add("aws", "iam", "delete-role-policy", 118 | "--role-name", fromRole.Name, 119 | "--policy-name", ip.Name) 120 | } 121 | // remove role 122 | a.cmds.Add("aws", "iam", "delete-role", 123 | "--role-name", fromRole.Name) 124 | } 125 | } 126 | for _, fromUser := range a.from.Users { 127 | if found, _ := a.to.FindUserByName(fromUser.Name, fromUser.Path); !found { 128 | // remove access keys 129 | accessKeys, mfaDevices, hasLoginProfile := iam.MustGetSecurityCredsForUser(fromUser.Name) 130 | for _, keyId := range accessKeys { 131 | a.cmds.Add("aws", "iam", "delete-access-key", 132 | "--user-name", fromUser.Name, 133 | "--access-key-id", keyId) 134 | } 135 | 136 | // remove mfa devices 137 | for _, mfaId := range mfaDevices { 138 | a.cmds.Add("aws", "iam", "deactivate-mfa-device", 139 | "--user-name", fromUser.Name, 140 | "--serial-number", mfaId) 141 | a.cmds.Add("aws", "iam", "delete-virtual-mfa-device", 142 | "--serial-number", mfaId) 143 | } 144 | 145 | // remove password 146 | if hasLoginProfile { 147 | a.cmds.Add("aws", "iam", "delete-login-profile", 148 | "--user-name", fromUser.Name) 149 | } 150 | 151 | // remove from groups 152 | for _, g := range fromUser.Groups { 153 | a.cmds.Add("aws", "iam", "remove-user-from-group", 154 | "--user-name", fromUser.Name, 155 | "--group-name", g) 156 | } 157 | 158 | // detach managed policies 159 | for _, p := range fromUser.Policies { 160 | a.cmds.Add("aws", "iam", "detach-user-policy", 161 | "--user-name", fromUser.Name, 162 | "--policy-arn", a.to.Account.policyArnFromString(p)) 163 | } 164 | 165 | // remove inline policies 166 | for _, ip := range fromUser.InlinePolicies { 167 | a.cmds.Add("aws", "iam", "delete-user-policy", 168 | "--user-name", fromUser.Name, 169 | "--policy-name", ip.Name) 170 | } 171 | 172 | // remove user 173 | a.cmds.Add("aws", "iam", "delete-user", 174 | "--user-name", fromUser.Name) 175 | } 176 | } 177 | for _, fromGroup := range a.from.Groups { 178 | if found, _ := a.to.FindGroupByName(fromGroup.Name, fromGroup.Path); !found { 179 | // detach managed policies 180 | for _, p := range fromGroup.Policies { 181 | a.cmds.Add("aws", "iam", "detach-group-policy", 182 | "--group-name", fromGroup.Name, 183 | "--policy-arn", a.to.Account.policyArnFromString(p)) 184 | } 185 | // remove inline policies 186 | for _, ip := range fromGroup.InlinePolicies { 187 | a.cmds.Add("aws", "iam", "delete-group-policy", 188 | "--group-name", fromGroup.Name, 189 | "--policy-name", ip.Name) 190 | } 191 | // remove group 192 | a.cmds.Add("aws", "iam", "delete-group", 193 | "--group-name", fromGroup.Name) 194 | } 195 | } 196 | for _, fromPolicy := range a.from.Policies { 197 | if found, _ := a.to.FindPolicyByName(fromPolicy.Name, fromPolicy.Path); !found { 198 | for _, v := range fromPolicy.nondefaultVersionIds { 199 | a.cmds.Add("aws", "iam", "delete-policy-version", 200 | "--version-id", v, 201 | "--policy-arn", Arn(fromPolicy, a.to.Account)) 202 | } 203 | a.cmds.Add("aws", "iam", "delete-policy", 204 | "--policy-arn", Arn(fromPolicy, a.to.Account)) 205 | } 206 | } 207 | } 208 | 209 | func (a *awsSyncCmdGenerator) updatePolicies() { 210 | // update policies 211 | for _, toPolicy := range a.to.Policies { 212 | if found, fromPolicy := a.from.FindPolicyByName(toPolicy.Name, toPolicy.Path); found { 213 | // Update policy 214 | if fromPolicy.Policy.JsonString() != toPolicy.Policy.JsonString() { 215 | 216 | if fromPolicy.numberOfVersions >= MaxAllowedPolicyVersions { 217 | a.cmds.Add("aws", "iam", "delete-policy-version", 218 | "--policy-arn", Arn(toPolicy, a.to.Account), 219 | "--version-id", fromPolicy.oldestVersionId) 220 | } 221 | 222 | a.cmds.Add("aws", "iam", "create-policy-version", 223 | "--policy-arn", Arn(toPolicy, a.to.Account), 224 | "--set-as-default", 225 | "--policy-document", toPolicy.Policy.JsonString(), 226 | ) 227 | } 228 | } else { 229 | // Create policy 230 | args := []string{ 231 | "iam", "create-policy", 232 | "--policy-name", toPolicy.Name, 233 | "--path", path(toPolicy.Path), 234 | } 235 | if toPolicy.Description != "" { 236 | args = append(args, "--description", toPolicy.Description) 237 | } 238 | // document last, for easier reading by end-user 239 | args = append(args, "--policy-document", toPolicy.Policy.JsonString()) 240 | a.cmds.Add("aws", args...) 241 | } 242 | } 243 | } 244 | 245 | func (a *awsSyncCmdGenerator) updateRoles() { 246 | 247 | // update roles 248 | for _, toRole := range a.to.Roles { 249 | if found, fromRole := a.from.FindRoleByName(toRole.Name, toRole.Path); found { 250 | // Update role 251 | if !reflect.DeepEqual(fromRole.AssumeRolePolicyDocument, toRole.AssumeRolePolicyDocument) { 252 | a.cmds.Add("aws", "iam", "update-assume-role-policy", 253 | "--role-name", toRole.Name, 254 | "--policy-document", toRole.AssumeRolePolicyDocument.JsonString()) 255 | } 256 | 257 | // remove old inline policies 258 | for _, ip := range inlinePolicySetDifference(fromRole.InlinePolicies, toRole.InlinePolicies) { 259 | a.cmds.Add("aws", "iam", "delete-role-policy", 260 | "--role-name", toRole.Name, 261 | "--policy-name", ip.Name) 262 | } 263 | 264 | // add new inline policies 265 | for _, ip := range inlinePolicySetDifference(toRole.InlinePolicies, fromRole.InlinePolicies) { 266 | a.cmds.Add("aws", "iam", "put-role-policy", 267 | "--role-name", toRole.Name, 268 | "--policy-name", ip.Name, 269 | "--policy-document", ip.Policy.JsonString()) 270 | } 271 | 272 | // detach old managed policies 273 | for _, p := range stringSetDifference(fromRole.Policies, toRole.Policies) { 274 | a.cmds.Add("aws", "iam", "detach-role-policy", 275 | "--role-name", toRole.Name, 276 | "--policy-arn", a.to.Account.policyArnFromString(p)) 277 | } 278 | 279 | // attach new managed policies 280 | for _, p := range stringSetDifference(toRole.Policies, fromRole.Policies) { 281 | a.cmds.Add("aws", "iam", "attach-role-policy", 282 | "--role-name", toRole.Name, 283 | "--policy-arn", a.to.Account.policyArnFromString(p)) 284 | } 285 | 286 | // update max session duration 287 | if fromRole.MaxSessionDuration != toRole.MaxSessionDuration { 288 | a.cmds.Add("aws", "iam", "update-role", 289 | "--role-name", toRole.Name, 290 | "--max-session-duration", strconv.Itoa(toRole.MaxSessionDuration)) 291 | } 292 | 293 | } else { 294 | // Create role 295 | args := []string{ 296 | "iam", "create-role", 297 | "--role-name", toRole.Name, 298 | "--path", path(toRole.Path), 299 | "--assume-role-policy-document", toRole.AssumeRolePolicyDocument.JsonString(), 300 | } 301 | if toRole.Description != "" { 302 | args = append(args, "--description", toRole.Description) 303 | } 304 | if toRole.MaxSessionDuration != 0 { 305 | args = append(args, "--max-session-duration", strconv.Itoa(toRole.MaxSessionDuration)) 306 | } 307 | a.cmds.Add("aws", args...) 308 | 309 | // add new inline policies 310 | for _, ip := range toRole.InlinePolicies { 311 | a.cmds.Add("aws", "iam", "put-role-policy", 312 | "--role-name", toRole.Name, 313 | "--policy-name", ip.Name, 314 | "--policy-document", ip.Policy.JsonString()) 315 | } 316 | 317 | // attach new managed policies 318 | for _, p := range toRole.Policies { 319 | a.cmds.Add("aws", "iam", "attach-role-policy", 320 | "--role-name", toRole.Name, 321 | "--policy-arn", a.to.Account.policyArnFromString(p)) 322 | } 323 | } 324 | } 325 | } 326 | 327 | func (a *awsSyncCmdGenerator) updateGroups() { 328 | // update groups 329 | for _, toGroup := range a.to.Groups { 330 | if found, fromGroup := a.from.FindGroupByName(toGroup.Name, toGroup.Path); found { 331 | 332 | // remove old inline policies 333 | for _, ip := range inlinePolicySetDifference(fromGroup.InlinePolicies, toGroup.InlinePolicies) { 334 | a.cmds.Add("aws", "iam", "delete-group-policy", 335 | "--group-name", toGroup.Name, 336 | "--policy-name", ip.Name) 337 | } 338 | 339 | // add new inline policies 340 | for _, ip := range inlinePolicySetDifference(toGroup.InlinePolicies, fromGroup.InlinePolicies) { 341 | a.cmds.Add("aws", "iam", "put-group-policy", 342 | "--group-name", toGroup.Name, 343 | "--policy-name", ip.Name, 344 | "--policy-document", ip.Policy.JsonString()) 345 | } 346 | 347 | // detach old managed policies 348 | for _, p := range stringSetDifference(fromGroup.Policies, toGroup.Policies) { 349 | a.cmds.Add("aws", "iam", "detach-group-policy", 350 | "--group-name", toGroup.Name, 351 | "--policy-arn", a.to.Account.policyArnFromString(p)) 352 | } 353 | 354 | // attach new managed policies 355 | for _, p := range stringSetDifference(toGroup.Policies, fromGroup.Policies) { 356 | a.cmds.Add("aws", "iam", "attach-group-policy", 357 | "--group-name", toGroup.Name, 358 | "--policy-arn", a.to.Account.policyArnFromString(p)) 359 | } 360 | 361 | } else { 362 | // Create group 363 | a.cmds.Add("aws", "iam", "create-group", 364 | "--group-name", toGroup.Name, 365 | "--path", path(toGroup.Path)) 366 | 367 | for _, ip := range toGroup.InlinePolicies { 368 | a.cmds.Add("aws", "iam", "put-group-policy", 369 | "--group-name", toGroup.Name, "--policy-name", ip.Name, 370 | "--policy-document", ip.Policy.JsonString()) 371 | } 372 | 373 | for _, p := range toGroup.Policies { 374 | a.cmds.Add("aws", "iam", "attach-group-policy", 375 | "--group-name", toGroup.Name, 376 | "--policy-arn", a.to.Account.policyArnFromString(p)) 377 | } 378 | 379 | } 380 | } 381 | } 382 | 383 | func (a *awsSyncCmdGenerator) updateUsers() { 384 | 385 | // update users 386 | for _, toUser := range a.to.Users { 387 | if found, fromUser := a.from.FindUserByName(toUser.Name, toUser.Path); found { 388 | 389 | // remove old groups 390 | for _, g := range stringSetDifference(fromUser.Groups, toUser.Groups) { 391 | a.cmds.Add("aws", "iam", "remove-user-from-group", 392 | "--user-name", toUser.Name, 393 | "--group-name", g) 394 | } 395 | 396 | // add new groups 397 | for _, g := range stringSetDifference(toUser.Groups, fromUser.Groups) { 398 | a.cmds.Add("aws", "iam", "add-user-to-group", 399 | "--user-name", toUser.Name, 400 | "--group-name", g) 401 | } 402 | 403 | // remove old inline policies 404 | for _, ip := range inlinePolicySetDifference(fromUser.InlinePolicies, toUser.InlinePolicies) { 405 | a.cmds.Add("aws", "iam", "delete-user-policy", 406 | "--user-name", toUser.Name, 407 | "--policy-name", ip.Name) 408 | } 409 | 410 | // add new inline policies 411 | for _, ip := range inlinePolicySetDifference(toUser.InlinePolicies, fromUser.InlinePolicies) { 412 | a.cmds.Add("aws", "iam", "put-user-policy", 413 | "--user-name", toUser.Name, 414 | "--policy-name", ip.Name, 415 | "--policy-document", ip.Policy.JsonString()) 416 | } 417 | 418 | // detach old managed policies 419 | for _, p := range stringSetDifference(fromUser.Policies, toUser.Policies) { 420 | a.cmds.Add("aws", "iam", "detach-user-policy", 421 | "--user-name", toUser.Name, 422 | "--policy-arn", a.to.Account.policyArnFromString(p)) 423 | } 424 | 425 | // attach new managed policies 426 | for _, p := range stringSetDifference(toUser.Policies, fromUser.Policies) { 427 | a.cmds.Add("aws", "iam", "attach-user-policy", 428 | "--user-name", toUser.Name, 429 | "--policy-arn", a.to.Account.policyArnFromString(p)) 430 | } 431 | 432 | // remove old tags 433 | for tagKey, _ := range mapStringSetDifference(fromUser.Tags, toUser.Tags) { 434 | a.cmds.Add("aws", "iam", "untag-user", 435 | "--user-name", toUser.Name, 436 | "--tag-keys", tagKey) 437 | } 438 | 439 | // attach new tags 440 | for tagKey, tagValue := range mapStringSetDifference(toUser.Tags, fromUser.Tags) { 441 | a.cmds.Add("aws", "iam", "tag-user", 442 | "--user-name", toUser.Name, 443 | "--tags", "Key="+tagKey+",Value="+tagValue) 444 | } 445 | 446 | } else { 447 | // Create user 448 | if len(toUser.Tags) == 0 { 449 | a.cmds.Add("aws", "iam", "create-user", 450 | "--user-name", toUser.Name, 451 | "--path", path(toUser.Path)) 452 | } else { 453 | a.cmds.Add("aws", "iam", "create-user", 454 | "--user-name", toUser.Name, 455 | "--path", path(toUser.Path), 456 | "--tags", mapTagsToString(toUser.Tags)) 457 | } 458 | 459 | // add new groups 460 | for _, g := range toUser.Groups { 461 | a.cmds.Add("aws", "iam", "add-user-to-group", 462 | "--user-name", toUser.Name, 463 | "--group-name", g) 464 | } 465 | 466 | // add new inline policies 467 | for _, ip := range toUser.InlinePolicies { 468 | a.cmds.Add("aws", "iam", "put-user-policy", 469 | "--user-name", toUser.Name, 470 | "--policy-name", ip.Name, 471 | "--policy-document", ip.Policy.JsonString()) 472 | } 473 | 474 | // attach new managed policies 475 | for _, p := range toUser.Policies { 476 | a.cmds.Add("aws", "iam", "attach-user-policy", 477 | "--user-name", toUser.Name, 478 | "--policy-arn", a.to.Account.policyArnFromString(p)) 479 | } 480 | } 481 | } 482 | } 483 | func (a *awsSyncCmdGenerator) updateInstanceProfiles() { 484 | // update instance profiles 485 | for _, toInstanceProfile := range a.to.InstanceProfiles { 486 | if found, fromInstanceProfile := a.from.FindInstanceProfileByName(toInstanceProfile.Name, toInstanceProfile.Path); found { 487 | // remove old roles from instance profile 488 | for _, role := range stringSetDifference(fromInstanceProfile.Roles, toInstanceProfile.Roles) { 489 | a.cmds.Add("aws", "iam", "remove-role-from-instance-profile", 490 | "--instance-profile-name", toInstanceProfile.Name, 491 | "--role-name", role) 492 | } 493 | 494 | // add new roles to instance profile 495 | for _, role := range stringSetDifference(toInstanceProfile.Roles, fromInstanceProfile.Roles) { 496 | a.cmds.Add("aws", "iam", "add-role-to-instance-profile", 497 | "--instance-profile-name", toInstanceProfile.Name, 498 | "--role-name", role) 499 | } 500 | } else { 501 | // Create instance profile 502 | a.cmds.Add("aws", "iam", "create-instance-profile", 503 | "--instance-profile-name", toInstanceProfile.Name, 504 | "--path", path(toInstanceProfile.Path)) 505 | for _, role := range toInstanceProfile.Roles { 506 | a.cmds.Add("aws", "iam", "add-role-to-instance-profile", 507 | "--instance-profile-name", toInstanceProfile.Name, 508 | "--role-name", role) 509 | } 510 | } 511 | } 512 | } 513 | 514 | func (a *awsSyncCmdGenerator) updateBucketPolicies() { 515 | for _, fromBucketPolicy := range a.from.BucketPolicies { 516 | if found, _ := a.to.FindBucketPolicyByBucketName(fromBucketPolicy.BucketName); !found { 517 | // remove bucket policy 518 | a.cmds.Add("aws", "s3api", "delete-bucket-policy", 519 | "--bucket", fromBucketPolicy.BucketName) 520 | } 521 | } 522 | 523 | for _, toBucketPolicy := range a.to.BucketPolicies { 524 | isToAccountUpToDate := false 525 | if found, fromBucketPolicy := a.from.FindBucketPolicyByBucketName(toBucketPolicy.BucketName); found { 526 | if fromBucketPolicy.Policy.JsonString() == toBucketPolicy.Policy.JsonString() { 527 | isToAccountUpToDate = true 528 | } 529 | } 530 | 531 | if !isToAccountUpToDate { 532 | a.cmds.Add("aws", "s3api", "put-bucket-policy", 533 | "--bucket", toBucketPolicy.BucketName, 534 | "--policy", toBucketPolicy.Policy.JsonString()) 535 | } 536 | } 537 | } 538 | 539 | func (a *awsSyncCmdGenerator) GenerateCmds() CmdList { 540 | a.updatePolicies() 541 | a.updateRoles() 542 | a.updateGroups() 543 | a.updateUsers() 544 | a.updateInstanceProfiles() 545 | a.updateBucketPolicies() 546 | a.deleteOldEntities() 547 | 548 | return a.cmds 549 | } 550 | 551 | func AwsCliCmdsForSync(from, to *AccountData) CmdList { 552 | a := awsSyncCmdGenerator{from, to, CmdList{}} 553 | return a.GenerateCmds() 554 | } 555 | --------------------------------------------------------------------------------