├── .github └── workflows │ ├── ci.yml │ └── tagpr.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── internal ├── awsclient │ ├── client.go │ ├── list_policy_documents.go │ └── simulate_custom_policy.go ├── cli │ └── cli.go ├── input │ ├── input.go │ ├── string_or_string_list.go │ └── string_or_string_list_test.go └── slogx │ ├── fatal.go │ └── string_ptr.go └── main.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: stable 16 | - run: go get -v ./... 17 | - name: build 18 | run: go build 19 | test: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version: stable 26 | - run: go get -t -v ./... 27 | - name: test 28 | run: go test -v ./... 29 | lint: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: actions/setup-go@v5 34 | with: 35 | go-version: stable 36 | - name: golangci-lint 37 | uses: golangci/golangci-lint-action@v6 38 | with: 39 | version: latest 40 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | tagpr: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: Songmu/tagpr@v1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - cyclop 5 | - depguard 6 | - err113 7 | - exhaustruct 8 | - exportloopref # deprecated 9 | - funlen 10 | - gochecknoglobals 11 | - gocritic 12 | - lll 13 | - mnd 14 | - musttag 15 | - nlreturn 16 | - tagalign 17 | - testpackage 18 | - varnamelen 19 | - wsl 20 | linters-settings: 21 | sloglint: 22 | kv-only: false 23 | attrs-only: true 24 | context: all 25 | static-msg: true 26 | key-naming-case: camel 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 utagawa kiki 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-iam-policy-sim 2 | 3 | A simple IAM Policy Simulator CLI 4 | 5 | ## Installation 6 | 7 | ```console 8 | $ go install github.com/utgwkk/aws-iam-policy-sim@latest 9 | ``` 10 | 11 | ## Usage 12 | 13 | First, prepare JSON like below (NOTE: you can use IAM policy document JSON directly!) 14 | 15 | ```json 16 | { 17 | "Statement": [ 18 | { 19 | "Action": [ 20 | "s3:PutObject", 21 | "s3:GetObject", 22 | "s3:GetObjectTagging", 23 | "s3:DeleteObject" 24 | ], 25 | "Resource": [ 26 | "arn:aws:s3:::example-bucket/*" 27 | ] 28 | }, 29 | { 30 | "Action": "s3:ListBucket", 31 | "Resource": "arn:aws:s3:::example-bucket" 32 | } 33 | ] 34 | } 35 | ``` 36 | 37 | Then, execute `aws-iam-policy-sim`. 38 | 39 | ```console 40 | $ aws-iam-policy-sim --role-name example-role < path/to/statement.json 41 | 2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:PutObject resource=arn:aws:s3:::example-bucket/* 42 | 2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:GetObject resource=arn:aws:s3:::example-bucket/* 43 | 2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:GetObjectTagging resource=arn:aws:s3:::example-bucket/* 44 | 2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:DeleteObject resource=arn:aws:s3:::example-bucket/* 45 | 2024-12-03 08:22:00 INF Allowed level=INFO msg=Allowed action=s3:ListBucket resource=arn:aws:s3:::example-bucket 46 | ``` 47 | 48 | If your IAM role lacks some permission, `aws-iam-policy-sim` reports an error. 49 | 50 | ```console 51 | $ aws-iam-policy-sim --role-name example-role < path/to/statement.json 52 | 2024-12-03 08:22:00 INF Allowed action=s3:PutObject resource=arn:aws:s3:::example-bucket/* 53 | 2024-12-03 08:22:00 INF Allowed action=s3:GetObject resource=arn:aws:s3:::example-bucket/* 54 | 2024-12-03 08:22:00 INF Allowed action=s3:GetObjectTagging resource=arn:aws:s3:::example-bucket/* 55 | 2024-12-03 08:22:00 INF Allowed action=s3:DeleteObject resource=arn:aws:s3:::example-bucket/* 56 | 2024-12-03 08:22:00 ERR Implicit deny action=s3:ListBucket resource=arn:aws:s3:::example-bucket 57 | ``` 58 | 59 | ## Limitations 60 | 61 | - `aws-iam-policy-sim` only supports a simulation for IAM Roles. Simulations for IAM Users or Groups are not supported now. 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/utgwkk/aws-iam-policy-sim 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/alecthomas/kong v1.5.1 7 | github.com/aws/aws-sdk-go-v2 v1.32.5 8 | github.com/aws/aws-sdk-go-v2/config v1.28.5 9 | github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 10 | github.com/lmittmann/tint v1.0.5 11 | ) 12 | 13 | require ( 14 | github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect 15 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 19 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect 24 | github.com/aws/smithy-go v1.22.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v1.5.1 h1:9quB93P2aNGXf5C1kWNei85vjBgITNJQA4dSwJQGCOY= 4 | github.com/alecthomas/kong v1.5.1/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo= 8 | github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= 9 | github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0= 10 | github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o= 11 | github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg= 12 | github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA= 13 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA= 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY= 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI= 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY= 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o= 18 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg= 19 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= 20 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 21 | github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 h1:hfkzDZHBp9jAT4zcd5mtqckpU4E3Ax0LQaEWWk1VgN8= 22 | github.com/aws/aws-sdk-go-v2/service/iam v1.38.1/go.mod h1:u36ahDtZcQHGmVm/r+0L1sfKX4fzLEMdCqiKRKkUMVM= 23 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4= 26 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E= 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM= 28 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y= 29 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8= 30 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc= 31 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU= 32 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg= 33 | github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= 34 | github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 35 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 36 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 37 | github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= 38 | github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 39 | -------------------------------------------------------------------------------- /internal/awsclient/client.go: -------------------------------------------------------------------------------- 1 | package awsclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/aws/aws-sdk-go-v2/config" 8 | "github.com/aws/aws-sdk-go-v2/service/iam" 9 | ) 10 | 11 | type Client struct { 12 | iamClient *iam.Client 13 | } 14 | 15 | func New(ctx context.Context) (*Client, error) { 16 | awscfg, err := config.LoadDefaultConfig(ctx) 17 | if err != nil { 18 | return nil, fmt.Errorf("failed to load default AWS config: %w", err) 19 | } 20 | 21 | iamClient := iam.NewFromConfig(awscfg) 22 | return &Client{ 23 | iamClient: iamClient, 24 | }, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/awsclient/list_policy_documents.go: -------------------------------------------------------------------------------- 1 | package awsclient 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "iter" 7 | "log/slog" 8 | "net/url" 9 | 10 | "github.com/aws/aws-sdk-go-v2/aws" 11 | "github.com/aws/aws-sdk-go-v2/service/iam" 12 | "github.com/aws/aws-sdk-go-v2/service/iam/types" 13 | "github.com/utgwkk/aws-iam-policy-sim/internal/slogx" 14 | ) 15 | 16 | func (c *Client) ListRolePolicyDocuments(ctx context.Context, roleName string) ([]string, error) { 17 | // The maximum number of managed policies per IAM role is 20. 18 | // ref: https://docs.aws.amazon.com/singlesignon/latest/userguide/limits.html 19 | policyDocuments := make([]string, 0, 20) 20 | 21 | for listedPolicy, err := range c.iterateRoleAttachedManagedPolicies(ctx, roleName) { 22 | if err != nil { 23 | return nil, fmt.Errorf("failed to iterateRoleAttachedManagedPolicies: %w", err) 24 | } 25 | 26 | slog.DebugContext(ctx, "Invoking GetPolicy", slog.String("policyArn", *listedPolicy.PolicyArn)) 27 | policy, err := c.iamClient.GetPolicy(ctx, &iam.GetPolicyInput{ 28 | PolicyArn: listedPolicy.PolicyArn, 29 | }) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to get policy: %w", err) 32 | } 33 | 34 | slog.DebugContext(ctx, "Invoking GetPolicyVersion", slog.String("policyName", *listedPolicy.PolicyName), slog.String("roleName", roleName)) 35 | defaultVersionPolicy, err := c.iamClient.GetPolicyVersion(ctx, &iam.GetPolicyVersionInput{ 36 | PolicyArn: policy.Policy.Arn, 37 | VersionId: policy.Policy.DefaultVersionId, 38 | }) 39 | if err != nil { 40 | return nil, fmt.Errorf("failed to get role policy: %w", err) 41 | } 42 | 43 | unescaped, err := unescapePolicyDocument(*defaultVersionPolicy.PolicyVersion.Document) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to unescape policy document: %w", err) 46 | } 47 | policyDocuments = append(policyDocuments, unescaped) 48 | } 49 | 50 | for policyName, err := range c.iterateRoleInlinePolicyNames(ctx, roleName) { 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to iterateRoleInlinePolicyNames: %w", err) 53 | } 54 | 55 | slog.DebugContext(ctx, "Invoking GetRolePolicy", slog.String("policyName", policyName)) 56 | policy, err := c.iamClient.GetRolePolicy(ctx, &iam.GetRolePolicyInput{ 57 | PolicyName: aws.String(policyName), 58 | RoleName: aws.String(roleName), 59 | }) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to get policy: %w", err) 62 | } 63 | 64 | unescaped, err := unescapePolicyDocument(*policy.PolicyDocument) 65 | if err != nil { 66 | return nil, fmt.Errorf("failed to unescape policy document: %w", err) 67 | } 68 | policyDocuments = append(policyDocuments, unescaped) 69 | } 70 | 71 | return policyDocuments, nil 72 | } 73 | 74 | func (c *Client) iterateRoleAttachedManagedPolicies(ctx context.Context, roleName string) iter.Seq2[types.AttachedPolicy, error] { 75 | return func(yield func(types.AttachedPolicy, error) bool) { 76 | var marker *string 77 | for { 78 | slog.DebugContext(ctx, "Invoking ListAttachedRolePolicies", slog.String("roleName", roleName), slogx.StringPtr("marker", marker)) 79 | res, err := c.iamClient.ListAttachedRolePolicies(ctx, &iam.ListAttachedRolePoliciesInput{ 80 | RoleName: aws.String(roleName), 81 | Marker: marker, 82 | }) 83 | if err != nil { 84 | yield(types.AttachedPolicy{}, err) 85 | return 86 | } 87 | slog.DebugContext(ctx, "ListAttachedRolePolicies", slog.Int("numAttachedPolicies", len(res.AttachedPolicies))) 88 | 89 | for _, policy := range res.AttachedPolicies { 90 | slog.DebugContext(ctx, "iterateRoleAttachedManagedPolicies loop", slog.String("policyName", *policy.PolicyName)) 91 | if !yield(policy, nil) { 92 | return 93 | } 94 | } 95 | if !res.IsTruncated { 96 | return 97 | } 98 | marker = res.Marker 99 | } 100 | } 101 | } 102 | 103 | func (c *Client) iterateRoleInlinePolicyNames(ctx context.Context, roleName string) iter.Seq2[string, error] { 104 | return func(yield func(string, error) bool) { 105 | var marker *string 106 | for { 107 | slog.DebugContext(ctx, "Invoking ListRolePolicies", slog.String("roleName", roleName), slogx.StringPtr("marker", marker)) 108 | res, err := c.iamClient.ListRolePolicies(ctx, &iam.ListRolePoliciesInput{ 109 | RoleName: aws.String(roleName), 110 | Marker: marker, 111 | }) 112 | if err != nil { 113 | yield("", err) 114 | return 115 | } 116 | slog.DebugContext(ctx, "ListRolePolicies", slog.Int("numPolicyNames", len(res.PolicyNames))) 117 | 118 | for _, policyName := range res.PolicyNames { 119 | slog.DebugContext(ctx, "iterateRoleInlinePolicyNames loop", slog.String("policyName", policyName)) 120 | if !yield(policyName, nil) { 121 | return 122 | } 123 | } 124 | if !res.IsTruncated { 125 | return 126 | } 127 | marker = res.Marker 128 | } 129 | } 130 | } 131 | 132 | func unescapePolicyDocument(policyDocument string) (string, error) { 133 | // A policy document obtained from GetRolePolicy or GetPolicyVersion is URL-encoded. 134 | // 135 | // refs: 136 | // - https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetRolePolicy.html 137 | // - https://docs.aws.amazon.com/IAM/latest/APIReference/API_GetPolicyVersion.html 138 | unescaped, err := url.QueryUnescape(policyDocument) 139 | if err != nil { 140 | return "", fmt.Errorf("unescapePolicyDocument: %w", err) 141 | } 142 | return unescaped, nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/awsclient/simulate_custom_policy.go: -------------------------------------------------------------------------------- 1 | package awsclient 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "iter" 8 | "log/slog" 9 | 10 | "github.com/aws/aws-sdk-go-v2/service/iam" 11 | "github.com/aws/aws-sdk-go-v2/service/iam/types" 12 | "github.com/utgwkk/aws-iam-policy-sim/internal/input" 13 | "github.com/utgwkk/aws-iam-policy-sim/internal/slogx" 14 | ) 15 | 16 | func (c *Client) SimulateIAMRolePolicies(ctx context.Context, roleName string, normalizedStmts []*input.NormalizedStatement) (bool, error) { 17 | policyDocuments, err := c.ListRolePolicyDocuments(ctx, roleName) 18 | if err != nil { 19 | return false, fmt.Errorf("failed to list role policy documents: %w", err) 20 | } 21 | 22 | if len(policyDocuments) == 0 { 23 | return false, errors.New("no policy is attached") 24 | } 25 | 26 | anyFailed := false 27 | for res, err := range c.simulateCustomPolicies(ctx, normalizedStmts, policyDocuments) { 28 | action, resource := *res.EvalActionName, *res.EvalResourceName 29 | if err != nil { 30 | slogx.FatalContext(ctx, "Failed to simulate custom policy", slog.Any("error", err)) 31 | } 32 | 33 | decisionType := res.EvalDecision 34 | switch decisionType { 35 | case types.PolicyEvaluationDecisionTypeAllowed: 36 | slog.InfoContext(ctx, "Allowed", slog.String("action", action), slog.String("resource", resource)) 37 | case types.PolicyEvaluationDecisionTypeImplicitDeny: 38 | slog.ErrorContext(ctx, "Implicit deny", slog.String("action", action), slog.String("resource", resource)) 39 | anyFailed = true 40 | case types.PolicyEvaluationDecisionTypeExplicitDeny: 41 | slog.ErrorContext(ctx, "Explicit deny", slog.String("action", action), slog.String("resource", resource)) 42 | anyFailed = true 43 | default: 44 | slog.ErrorContext(ctx, "Unexpected decision type", slog.String("action", action), slog.String("resource", resource), slog.Any("decisionType", decisionType)) 45 | anyFailed = true 46 | } 47 | } 48 | return anyFailed, nil 49 | } 50 | 51 | func (c *Client) simulateCustomPolicies(ctx context.Context, normalizedStmts []*input.NormalizedStatement, policyDocuments []string) iter.Seq2[types.EvaluationResult, error] { 52 | return func(yield func(types.EvaluationResult, error) bool) { 53 | for _, stmt := range normalizedStmts { 54 | for _, action := range stmt.Actions { 55 | for _, resource := range stmt.Resources { 56 | for res, err := range c.simulateCustomPolicy(ctx, policyDocuments, action, resource) { 57 | if !yield(res, err) { 58 | return 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | func (c *Client) simulateCustomPolicy(ctx context.Context, policyDocuments []string, action, resource string) iter.Seq2[types.EvaluationResult, error] { 68 | return func(yield func(types.EvaluationResult, error) bool) { 69 | var marker *string 70 | for { 71 | slog.DebugContext(ctx, "Invoking SimulateCustomPolicy", slog.String("action", action), slog.String("resource", resource), slogx.StringPtr("marker", marker)) 72 | res, err := c.iamClient.SimulateCustomPolicy(ctx, &iam.SimulateCustomPolicyInput{ 73 | ActionNames: []string{action}, 74 | PolicyInputList: policyDocuments, 75 | ResourceArns: []string{resource}, 76 | Marker: marker, 77 | }) 78 | if err != nil { 79 | yield(types.EvaluationResult{}, nil) 80 | return 81 | } 82 | 83 | for _, result := range res.EvaluationResults { 84 | if !yield(result, nil) { 85 | return 86 | } 87 | } 88 | if !res.IsTruncated { 89 | return 90 | } 91 | marker = res.Marker 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/utgwkk/aws-iam-policy-sim/internal/awsclient" 10 | "github.com/utgwkk/aws-iam-policy-sim/internal/input" 11 | "github.com/utgwkk/aws-iam-policy-sim/internal/slogx" 12 | ) 13 | 14 | type CLI struct { 15 | RoleName string `required:"" help:"IAM role name to simulate" long:"role-name"` 16 | 17 | Debug bool `help:"Enable debug output" long:"debug"` 18 | } 19 | 20 | func (c *CLI) Do(ctx context.Context) { 21 | if c.Debug { 22 | slog.SetLogLoggerLevel(slog.LevelDebug) 23 | } 24 | 25 | targetRoleName := c.RoleName 26 | 27 | client, err := awsclient.New(ctx) 28 | if err != nil { 29 | slogx.FatalContext(ctx, "Failed to initialize client", slog.Any("error", err)) 30 | } 31 | 32 | simulateInput := &input.Input{} 33 | slog.DebugContext(ctx, "Reading input from STDIN") 34 | if err := json.NewDecoder(os.Stdin).Decode(&simulateInput); err != nil { 35 | slogx.FatalContext(ctx, "Failed to read input from STDIN", slog.Any("error", err)) 36 | } 37 | slog.DebugContext(ctx, "Input decoded", slog.Int("numSimulates", len(simulateInput.Statement))) 38 | if len(simulateInput.Statement) == 0 { 39 | slogx.FatalContext(ctx, "No simulates specified") 40 | } 41 | 42 | normalizedStmts := make([]*input.NormalizedStatement, len(simulateInput.Statement)) 43 | for i, stmt := range simulateInput.Statement { 44 | normalized, err := stmt.Normalize() 45 | if err != nil { 46 | slogx.FatalContext(ctx, "Error when normalizing input", slog.Int("index", i), slog.Any("error", err)) 47 | } 48 | normalizedStmts[i] = normalized 49 | } 50 | 51 | anyFailed, err := client.SimulateIAMRolePolicies(ctx, targetRoleName, normalizedStmts) 52 | if err != nil { 53 | slogx.FatalContext(ctx, "Failed to simulate", slog.Any("error", err)) 54 | } 55 | 56 | if anyFailed { 57 | os.Exit(1) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/input/input.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | type Input struct { 8 | Statement []Statement 9 | } 10 | 11 | type Statement struct { 12 | Action StringOrStringList 13 | 14 | Resource StringOrStringList 15 | } 16 | 17 | type NormalizedStatement struct { 18 | Actions []string 19 | 20 | Resources []string 21 | } 22 | 23 | func (s *Statement) Normalize() (*NormalizedStatement, error) { 24 | if len(s.Action) == 0 { 25 | return nil, errors.New("action must not be empty") 26 | } 27 | if len(s.Resource) == 0 { 28 | return nil, errors.New("resource must not be empty") 29 | } 30 | 31 | return &NormalizedStatement{ 32 | Actions: []string(s.Action), 33 | Resources: []string(s.Resource), 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/input/string_or_string_list.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | ) 8 | 9 | type StringOrStringList []string 10 | 11 | func (s *StringOrStringList) UnmarshalJSON(b []byte) error { 12 | xs := []string{} 13 | switch { 14 | case bytes.HasPrefix(b, []byte("[")): 15 | if err := json.Unmarshal(b, &xs); err != nil { 16 | return fmt.Errorf("json.Unmarshal: %w", err) 17 | } 18 | case bytes.HasPrefix(b, []byte(`"`)): 19 | var t string 20 | if err := json.Unmarshal(b, &t); err != nil { 21 | return fmt.Errorf("json.Unmarshal: %w", err) 22 | } 23 | xs = []string{t} 24 | default: 25 | return fmt.Errorf("unexpected json value: %v", b) 26 | } 27 | *s = StringOrStringList(xs) 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/input/string_or_string_list_test.go: -------------------------------------------------------------------------------- 1 | package input 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestStringOrStringListUnmarshalJSON(t *testing.T) { 10 | t.Parallel() 11 | 12 | testcases := []struct { 13 | name string 14 | input string 15 | want StringOrStringList 16 | }{ 17 | { 18 | name: "Unmarshal string", 19 | input: `"foo"`, 20 | want: StringOrStringList{"foo"}, 21 | }, 22 | { 23 | name: "Unmarshal string list", 24 | input: `["foo","bar"]`, 25 | want: StringOrStringList{"foo", "bar"}, 26 | }, 27 | } 28 | for _, tt := range testcases { 29 | t.Run(tt.name, func(t *testing.T) { 30 | t.Parallel() 31 | 32 | dst := StringOrStringList{} 33 | if err := json.Unmarshal([]byte(tt.input), &dst); err != nil { 34 | t.Fatalf("Error unmarshaling JSON: %v", err) 35 | } 36 | 37 | if !reflect.DeepEqual(tt.want, dst) { 38 | t.Errorf("Result mismatch\n\twant: %s\n\tgot: %s", tt.want, dst) 39 | } 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/slogx/fatal.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | // FatalContext is equivalent to ErrorContext followed by os.Exit(1). 10 | func FatalContext(ctx context.Context, msg string, args ...any) { 11 | //nolint:sloglint 12 | slog.ErrorContext(ctx, msg, args...) 13 | os.Exit(1) 14 | } 15 | -------------------------------------------------------------------------------- /internal/slogx/string_ptr.go: -------------------------------------------------------------------------------- 1 | package slogx 2 | 3 | import "log/slog" 4 | 5 | func StringPtr(key string, value *string) slog.Attr { 6 | if value != nil { 7 | return slog.String(key, *value) 8 | } 9 | return slog.Any(key, nil) 10 | } 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "os/signal" 8 | "time" 9 | 10 | "github.com/alecthomas/kong" 11 | "github.com/lmittmann/tint" 12 | "github.com/utgwkk/aws-iam-policy-sim/internal/cli" 13 | "github.com/utgwkk/aws-iam-policy-sim/internal/slogx" 14 | ) 15 | 16 | func main() { 17 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) 18 | defer cancel() 19 | 20 | logHandler := tint.NewHandler(os.Stderr, &tint.Options{ 21 | Level: slog.LevelInfo, 22 | TimeFormat: time.DateTime, 23 | }) 24 | slog.SetDefault(slog.New(logHandler)) 25 | 26 | var cli cli.CLI 27 | parser, err := kong.New(&cli) 28 | if err != nil { 29 | slogx.FatalContext(ctx, "failed to initialize kong parser", slog.Any("error", err)) 30 | } 31 | if _, err := parser.Parse(os.Args[1:]); err != nil { 32 | slogx.FatalContext(ctx, "failed to parse args", slog.Any("error", err)) 33 | } 34 | 35 | cli.Do(ctx) 36 | } 37 | --------------------------------------------------------------------------------