├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── aws_iam_utils ├── __init__.py ├── action_data_overrides.py ├── checks.py ├── combiner.py ├── constants.py ├── generator.py ├── policy.py ├── policy_permission_item.py ├── simplifier.py └── util.py ├── setup.py └── tests ├── __init__.py ├── context.py ├── test_action_data_overrides.py ├── test_combiner_collapse_policy_statements.py ├── test_combiner_combine_policy_statements.py ├── test_dedupe_policy.py ├── test_generator.py ├── test_is_list_only_policy.py ├── test_is_read_only_policy.py ├── test_is_read_write_policy.py ├── test_policies_are_equal.py ├── test_policy.py └── test_simplifier.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=88 3 | extend-ignore=E203 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | 4 | build 5 | dist 6 | *.egg-info 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: check-case-conflict 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | #- id: no-commit-to-branch # protects main/master by default 12 | - id: trailing-whitespace 13 | 14 | - repo: https://github.com/ambv/black 15 | # must run before flake8 16 | rev: 22.3.0 17 | hooks: 18 | - id: black 19 | language_version: python3.10 20 | 21 | - repo: https://github.com/pycqa/flake8 22 | rev: 4.0.1 23 | hooks: 24 | - id: flake8 25 | 26 | - repo: https://github.com/yelp/detect-secrets 27 | rev: v1.1.0 28 | hooks: 29 | - id: detect-secrets 30 | 31 | - repo: https://github.com/trailofbits/pip-audit 32 | rev: v2.3.1 33 | hooks: 34 | - id: pip-audit 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Jonny Tyers 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .SHELLFLAGS = -ec 2 | .ONESHELL: 3 | 4 | .PHONY: init 5 | init: 6 | pip install -r requirements.txt 7 | 8 | 9 | .PHONY: test 10 | test: 11 | pytest tests 12 | 13 | .PHONY: build_dist 14 | build_dist: test 15 | # nb: if this step fails, do pip install wheel 16 | python setup.py sdist bdist_wheel 17 | twine check dist/* 18 | 19 | .PHONY: check-twine-env-vars 20 | check-twine-env-vars: 21 | @if [ -z "$$TWINE_USERNAME" ]; then 22 | echo "TWINE_USERNAME not set" >&2 23 | exit 1 24 | fi 25 | 26 | if [ -z "$$TWINE_PASSWORD" ]; then 27 | echo "TWINE_PASSWORD not set" >&2 28 | exit 1 29 | fi 30 | 31 | 32 | .PHONY: publish-test 33 | publish-test: check-twine-env-vars 34 | # nb: if this step fails, do pip install twine 35 | twine upload \ 36 | --non-interactive \ 37 | --repository-url https://test.pypi.org/legacy/ \ 38 | dist/* 39 | 40 | @echo "Published to TestPyPI. To try installing, do:" >&2 41 | @echo " pip install -i https://test.pypi.org/simple/ \\" >&2 42 | @echo " --extra-index-url https://pypi.org/simple/ \\" >&2 43 | @echo " aws-iam-utils" >&2 44 | @echo "" >&2 45 | 46 | .PHONY: publish 47 | publish: check-twine-env-vars 48 | twine upload \ 49 | --non-interactive \ 50 | dist/* 51 | 52 | @echo "Published to PyPI. To try installing, do:" >&2 53 | @echo " pip install aws-iam-utils" >&2 54 | @echo "" >&2 55 | 56 | .PHONY: clean 57 | clean: 58 | rm -rf dist build 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-iam-utils 2 | 3 | aws-iam-utils is a Python library with some utility functions for working with AWS IAM policies. I wrote this because, although many awesome AWS utility libraries exist, there was no simple toolset I could find that brings them together for practical use when you want the minimum code/scripting to get things done, particularly without learning a complex API. 4 | 5 | ## Features 6 | 7 | aws-iam-utils allows you to: 8 | 9 | * check if two policies provide the same permissions (taking account of wildcards) 10 | 11 | * check the level of access a policy provides (i.e. list, read-only, write, tagging or permissions-management) so you can verify that a policy does what you think it does 12 | 13 | * combine two policies together (i.e. merge their `Statement`s) 14 | 15 | * collapse multiple policies in order to minimise or enhance readability (see below) 16 | 17 | * generate list-only, read-only, read-write or full-access policies for any AWS service (with built-in assertions that the generated policies are correct according to the checks above) 18 | 19 | * simplify policies by changing arrays for Actions, Resources and Principals into strings if they contain only one item 20 | 21 | See example code below to get started. 22 | 23 | There is an extensive test suite covering all features. Remember it is your responsibility to use this code wisely and satisfy yourself that its outputs are secure for your needs. 24 | 25 | All the data that supports the policy generation and access levels comes from the excellent `policyuniverse` and `policy_sentry` libraries, which in turn get their data from AWS's own API documentation. 26 | 27 | ## Installation 28 | 29 | As easy as: 30 | 31 | ``` 32 | pip install aws-iam-utils 33 | ``` 34 | 35 | ## Examples 36 | 37 | Here are simple examples for all the use cases above. More example code can be found in the `tests` directory. 38 | 39 | ### Check if two policies are equal 40 | 41 | ```python 42 | from aws_iam_utils.checks import policies_are_equal 43 | 44 | first_policy = { 45 | "Version": "2012-10-17", 46 | "Statement": [{ 47 | "Effect": "Allow", 48 | "Action": [ 49 | "s3:PutObject", 50 | "s3:PutObjectVersionAcl", 51 | "s3:PutObjectVersionTagging", 52 | ], 53 | "Resource": "*", 54 | }] 55 | } 56 | 57 | second_policy = { 58 | "Version": "2012-10-17", 59 | "Statement": [ 60 | { 61 | "Effect": "Allow", 62 | "Action": "s3:PutObject", 63 | "Resource": "*", 64 | }, 65 | { 66 | "Effect": "Allow", 67 | "Action": [ 68 | "s3:PutObjectVersionAcl", 69 | "s3:PutObjectVersionTagging", 70 | ], 71 | "Resource": "*", 72 | } 73 | ] 74 | } 75 | 76 | print(policies_are_equal(first_policy, second_policy)) 77 | # True 78 | ``` 79 | 80 | ### Check the level of access a policy provides 81 | 82 | ```python 83 | from aws_iam_utils.checks import is_list_only_policy 84 | from aws_iam_utils.util import create_policy 85 | 86 | p = create_policy({ 87 | "Effect": "Allow", 88 | "Action": [ 89 | "s3:ListBucket", 90 | "s3:ListBucketVersions", 91 | "s3:GetObjectVersion", 92 | ], 93 | "Resource": "*", 94 | }) 95 | 96 | print(is_list_only_policy(p)) 97 | # False (because GetObjectVersion is a read operation) 98 | ``` 99 | 100 | There is also `is_read_only_policy()` (which returns True if the policy allows only read and list operations), and `is_read_write_policy()` (which returns True if the policy allows only read, list and write operations, but not tagging or permissions management operations). 101 | 102 | Notice the call to `create_policy()`? This is a simple function that creates the boilerplate `Version` and `Statement` fields for you, simply pass in one or more `Statement`s as dicts. It helps to cut down (just slightly) on repetitive code. The latest version (`2012-10-17`) is used by default but can be overridden with `create_policy(..., version='new_version')`. Using `create_policy` is completely optional. 103 | 104 | ### Combine policies together 105 | 106 | `aws-iam-utils` allows you to merge policy documents, which simply means concatenating `Statement`s together. This is useful for policies generated elsewhere (e.g. by `aws_iam_utils` or other tools) that you want to use together. 107 | 108 | ```python 109 | from aws_iam_utils.combiner import combine_policy_statements 110 | 111 | first_policy = { 112 | "Version": "2012-10-17", 113 | "Statement": [{ 114 | "Effect": "Allow", 115 | "Action": [ 116 | "s3:PutObject", 117 | "s3:PutObjectVersionAcl", 118 | "s3:PutObjectVersionTagging", 119 | ], 120 | "Resource": "*", 121 | }] 122 | } 123 | 124 | second_policy = { 125 | "Version": "2012-10-17", 126 | "Statement": [ 127 | { 128 | "Effect": "Allow", 129 | "Action": [ 130 | "s3:PutObjectVersionAcl", 131 | "s3:PutObjectVersionTagging", 132 | ], 133 | "Resource": "*", 134 | } 135 | ] 136 | } 137 | 138 | print(combine_policy_statements(first_policy, second_policy)) 139 | # { 140 | # "Version": "2012-10-17", 141 | # "Statement": [ 142 | # { 143 | # "Effect": "Allow", 144 | # "Action": [ 145 | # "s3:PutObjectVersionAcl", 146 | # "s3:PutObjectVersionTagging", 147 | # ], 148 | # "Resource": "*", 149 | # }, 150 | # { 151 | # "Effect": "Allow", 152 | # "Action": [ 153 | # "s3:PutObject", 154 | # "s3:PutObjectVersionAcl", 155 | # "s3:PutObjectVersionTagging", 156 | # ], 157 | # "Resource": "*", 158 | # } 159 | # ] 160 | # } 161 | ``` 162 | 163 | `combine_policy_statements` is a simple concatenation of `Statement`s with no intelligence whatsoever. If you also want to merge `Statement`s together where it is safe to do so, see collapsing in the next section. 164 | 165 | ### Collapse policies together 166 | 167 | Where *combining* policies is a simple concatenation operation, *collapsing* one or more policies examines the statements and merges together those statements that apply the same effect to the same Principals, Resources and Conditions. 168 | 169 | For example, let's repeat the example we had above: 170 | 171 | ```python 172 | from aws_iam_utils.combiner import collapse_policy_statements 173 | 174 | first_policy = { 175 | "Version": "2012-10-17", 176 | "Statement": [{ 177 | "Effect": "Allow", 178 | "Action": [ 179 | "s3:PutObject", 180 | "s3:PutObjectVersionAcl", 181 | "s3:PutObjectVersionTagging", 182 | ], 183 | "Resource": "*", 184 | }] 185 | } 186 | 187 | second_policy = { 188 | "Version": "2012-10-17", 189 | "Statement": [ 190 | { 191 | "Effect": "Allow", 192 | "Action": [ 193 | "s3:PutObjectVersionAcl", 194 | "s3:PutObjectVersionTagging", 195 | ], 196 | "Resource": "*", 197 | } 198 | ] 199 | } 200 | 201 | print(collapse_policy_statements(first_policy, second_policy)) 202 | # { 203 | # "Version": "2012-10-17", 204 | # "Statement": [ 205 | # { 206 | # "Effect": "Allow", 207 | # "Action": [ 208 | # "s3:PutObjectVersionAcl", 209 | # "s3:PutObjectVersionTagging", 210 | # "s3:PutObject", 211 | # ], 212 | # "Resource": "*", 213 | # } 214 | # ] 215 | # } 216 | ``` 217 | 218 | Note how any duplicates are removed, and because all the Actions related to the same Resource, they were merged. If the Resource differs, the merge would not take place. Effect, Principals and Conditions are also included in the comparison: if any of those differ, the statement is not merged. 219 | 220 | ### Generate policies 221 | 222 | This is a simple policy-generation API that generates policies for a particular service based on an access level (read, write, list, tagging or permissions management). 223 | 224 | ```python 225 | 226 | from aws_iam_utils.generator import generate_read_only_policy_for_service 227 | 228 | print(generate_read_only_policy_for_service('kinesis')) 229 | # { 230 | # "Version": "2012-10-17", 231 | # "Statement": [ 232 | # { 233 | # "Effect": "Allow", 234 | # "Action": [ 235 | # "kinesis:Describe*", 236 | # "kinesis:Get*", 237 | # "kinesis:List*", 238 | # "kinesis:Subscribe*" 239 | # ], 240 | # "Resource": "*" 241 | # } 242 | # ] 243 | # } 244 | ``` 245 | 246 | The generation engine will by default try to use verb wildcards, as you see above. You can turn this off by calling the generate function with `use_wildcard_verbs=False`. When using wildcards the generator verifies that the wildcards do not provide any extra permissions (e.g. where `s3:Put*` would include `s3:PutBucketPolicy`) - if the wildcards would result in extra permissions being given beyond the access level requested, the entire actions list is returned instead. 247 | 248 | There is also `generate_read_write_policy_for_service` and `generate_list_only_policy_for_service`, and `generate_full_policy_for_service`. 249 | 250 | You can now generate policies that cater to specific ARN types as well. For example, to create a policy that can read/write S3 objects, but not buckets: 251 | 252 | ```python 253 | from aws_iam_utils.generator import generate_read_write_policy_for_service_arn_type 254 | 255 | print(generate_read_write_policy_for_service_arn_type('s3','object')) 256 | # { 257 | # "Version": "2012-10-17", 258 | # "Statement": [ 259 | # { 260 | # "Effect": "Allow", 261 | # "Action": [ 262 | # "s3:AbortMultipartUpload", 263 | # "s3:DeleteObject", 264 | # "s3:DeleteObjectVersion", 265 | # "s3:GetObject", 266 | # "s3:GetObjectAcl", 267 | # "s3:GetObjectAttributes", 268 | # "s3:GetObjectLegalHold", 269 | # "s3:GetObjectRetention", 270 | # "s3:GetObjectTagging", 271 | # "s3:GetObjectTorrent", 272 | # "s3:GetObjectVersion", 273 | # "s3:GetObjectVersionAcl", 274 | # "s3:GetObjectVersionAttributes", 275 | # "s3:GetObjectVersionForReplication", 276 | # "s3:GetObjectVersionTagging", 277 | # "s3:GetObjectVersionTorrent", 278 | # "s3:InitiateReplication", 279 | # "s3:ListMultipartUploadParts", 280 | # "s3:PutObject", 281 | # "s3:PutObjectLegalHold", 282 | # "s3:PutObjectRetention", 283 | # "s3:ReplicateDelete", 284 | # "s3:ReplicateObject", 285 | # "s3:RestoreObject" 286 | # ], 287 | # "Resource": "*" 288 | # } 289 | # ] 290 | # } 291 | ``` 292 | 293 | # Documentation 294 | 295 | Coming soon. In the meantime each function already has documentation - check the sources. For example usage, see the tests. 296 | 297 | # Licence 298 | 299 | MIT 300 | -------------------------------------------------------------------------------- /aws_iam_utils/__init__.py: -------------------------------------------------------------------------------- 1 | import aws_iam_utils.checks 2 | import aws_iam_utils.generator 3 | import aws_iam_utils.combiner 4 | import aws_iam_utils.util 5 | import aws_iam_utils.simplifier 6 | import aws_iam_utils.action_data_overrides # noqa: F401 7 | import aws_iam_utils.policy 8 | import aws_iam_utils.policy_permission_item # noqa: F401 9 | -------------------------------------------------------------------------------- /aws_iam_utils/action_data_overrides.py: -------------------------------------------------------------------------------- 1 | from aws_iam_utils.constants import READ, WRITE, LIST 2 | 3 | """ 4 | This is a mapping of actions to overridden action data. This is primarily used to 5 | provide missing/incorrect results from policy_sentry's get_action_data. 6 | """ 7 | ACTION_DATA_OVERRIDES = { 8 | x["action"].lower(): x 9 | for x in [ 10 | { 11 | "action": "lambda:CreateFunctionUrlConfig", 12 | "access_level": WRITE, 13 | }, 14 | { 15 | "action": "lambda:GetFunctionurlConfig", 16 | "access_level": READ, 17 | }, 18 | { 19 | "action": "lambda:DeleteFunctionUrlConfig", 20 | "access_level": WRITE, 21 | }, 22 | { 23 | "action": "lambda:UpdateFunctionUrlConfig", 24 | "access_level": WRITE, 25 | }, 26 | { 27 | "action": "lambda:ListFunctionurlConfigs", 28 | "access_level": LIST, 29 | }, 30 | { 31 | "action": "lambda:InvokeFunctionUrl", 32 | "access_level": READ, 33 | }, 34 | { 35 | "action": "events:ListEndpoints", 36 | "access_level": LIST, 37 | }, 38 | { 39 | "action": "events:DescribeEndpoint", 40 | "access_level": READ, 41 | }, 42 | { 43 | "action": "wafv2:ListMobileSdkReleases", 44 | "access_level": LIST, 45 | }, 46 | { 47 | "action": "wafv2:GetMobileSdkRelease", 48 | "access_level": READ, 49 | }, 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /aws_iam_utils/checks.py: -------------------------------------------------------------------------------- 1 | from policyuniverse.expander_minimizer import expand_policy 2 | from policy_sentry.querying.actions import get_actions_matching_arn_type 3 | from policy_sentry.querying.actions import get_actions_that_support_wildcard_arns_only 4 | 5 | from aws_iam_utils.constants import READ, LIST, WRITE, WILDCARD_ARN_TYPE 6 | from aws_iam_utils.util import extract_policy_permission_items 7 | from aws_iam_utils.util import get_action_data_with_overrides 8 | 9 | 10 | def policies_are_equal(p1: dict, p2: dict) -> bool: 11 | """ 12 | Checks whether two policies give the same permissions. This will expand 13 | all wildcards and Resource constraints and then compare the result. 14 | 15 | @param p1 The first policy. Should be a dict that contains a Statement 16 | key, which should be a list of dicts conforming to the AWS IAM 17 | Policy schema. 18 | @param p2 The second policy, same format as p1. 19 | 20 | @returns True if p1 and p2 represent exactly the same permissions, or 21 | False otherwise. 22 | """ 23 | return extract_policy_permission_items( 24 | expand_policy(p1) 25 | ) == extract_policy_permission_items(expand_policy(p2)) 26 | 27 | 28 | def policy_has_only_these_access_levels(p: dict, access_levels: list[str]) -> bool: 29 | """ 30 | Returns True if all actions granted under the given policy are Read or 31 | List actions. 32 | """ 33 | p_items = extract_policy_permission_items(expand_policy(p)) 34 | for item in p_items: 35 | action_service, action_name = item["action"].split(":") 36 | 37 | action_output = get_action_data_with_overrides(action_service, action_name) 38 | 39 | if action_output is False: 40 | raise ValueError(f'invalid action: {item["action"]}') 41 | 42 | for action_output_action in action_output[action_service]: 43 | if action_output_action["action"].lower() != item["action"].lower(): 44 | continue 45 | 46 | if action_output_action["access_level"] not in access_levels: 47 | return False 48 | 49 | return True 50 | 51 | 52 | def is_read_only_policy(p: dict) -> bool: 53 | """ 54 | Returns True if all actions granted under the given policy are Read or 55 | List actions. 56 | """ 57 | return policy_has_only_these_access_levels(p, [READ, LIST]) 58 | 59 | 60 | def is_list_only_policy(p: dict) -> bool: 61 | """ 62 | Returns True if all actions granted under the given policy are List 63 | actions. 64 | """ 65 | return policy_has_only_these_access_levels(p, [LIST]) 66 | 67 | 68 | def is_read_write_policy(p: dict) -> bool: 69 | """ 70 | Returns True if all actions granted under the given policy are Read, 71 | List or Write actions. 72 | """ 73 | return policy_has_only_these_access_levels(p, [READ, LIST, WRITE]) 74 | 75 | 76 | def policy_has_only_these_arn_types( 77 | p: dict, service_name: str, arn_types: list[str] 78 | ) -> bool: 79 | """ 80 | Returns True if all actions granted under the given policy relate to the 81 | given ARN types only. Use `aws_iam_utils.constants.WILDCARD_ARN_TYPE` to 82 | refer to actions that do not relate to an ARN type (so-called "wildcard 83 | actions" in policy_sentry). 84 | """ 85 | arn_type_actions = {} 86 | for arn_type in arn_types: 87 | if arn_type == WILDCARD_ARN_TYPE: 88 | arn_type_actions[arn_type] = [ 89 | x.lower() 90 | for x in get_actions_that_support_wildcard_arns_only(service_name) 91 | ] 92 | else: 93 | arn_type_actions[arn_type] = [ 94 | x.lower() for x in get_actions_matching_arn_type(service_name, arn_type) 95 | ] 96 | 97 | p_items = extract_policy_permission_items(expand_policy(p)) 98 | for item in p_items: 99 | action_service, action_name = item["action"].split(":") 100 | 101 | action_output = get_action_data_with_overrides(action_service, action_name) 102 | 103 | if action_output is False: 104 | raise ValueError(f'invalid action: {item["action"]}') 105 | 106 | action_found = False 107 | for arn_type in arn_types: 108 | if item["action"].lower() in arn_type_actions[arn_type]: 109 | action_found = True 110 | break 111 | 112 | if action_found is False: 113 | return False 114 | 115 | return True 116 | -------------------------------------------------------------------------------- /aws_iam_utils/combiner.py: -------------------------------------------------------------------------------- 1 | import json 2 | from itertools import chain 3 | 4 | from aws_iam_utils.util import extract_policy_permission_items 5 | from aws_iam_utils.util import create_policy 6 | 7 | 8 | def combine_policy_statements(*policies: dict) -> dict: 9 | """ 10 | Merges the Statements for all the given policies into a single policy. 11 | """ 12 | 13 | if len(policies) == 0: 14 | return create_policy() 15 | 16 | return create_policy( 17 | *list(chain(*[p["Statement"] for p in policies])), 18 | version=policies[0]["Version"], 19 | ) 20 | 21 | 22 | def collapse_policy_statements(*policies: dict) -> dict: 23 | """ 24 | Attempts to merge policy statements together as far as possible, in order to 25 | simplify and shorten your policy. All statements with equal Effect, Condition, 26 | Principal and Resource keys will have their Actions merged together. 27 | """ 28 | 29 | # create a single policy with all Statements combined together 30 | combined_policy = combine_policy_statements(*policies) 31 | 32 | items = extract_policy_permission_items(combined_policy) 33 | 34 | # to combine, we group all actions by their effect/resource/condition/principal, 35 | # and then generate a new policy with statements for each unique combination 36 | # of those 37 | actions_by_qualifiers = {} 38 | 39 | for item in items: 40 | # turn into json so we can use nested dicts/lists/etc as dict keys 41 | k = json.dumps( 42 | [item["effect"], item["condition"], item["resource"], item["principal"]] 43 | ) 44 | 45 | if k not in actions_by_qualifiers: 46 | actions_by_qualifiers[k] = [] 47 | 48 | actions_by_qualifiers[k].append(item["action"]) 49 | 50 | new_policy_statements = [] 51 | for qualifiers, actions in actions_by_qualifiers.items(): 52 | new_statement = {} 53 | qualifiers_loaded = json.loads(qualifiers) 54 | 55 | for k, v in { 56 | "Effect": qualifiers_loaded[0], 57 | "Condition": qualifiers_loaded[1], 58 | "Resource": qualifiers_loaded[2], 59 | "Principal": qualifiers_loaded[3], 60 | }.items(): 61 | if v is not None: 62 | new_statement[k] = v 63 | 64 | # finally, remove duplicate actions while retaining original order 65 | # https://stackoverflow.com/a/25560184 66 | deduped_actions = sorted(set(actions), key=lambda x: actions.index(x)) 67 | 68 | new_statement["Action"] = deduped_actions 69 | 70 | new_policy_statements.append(new_statement) 71 | 72 | return {"Version": combined_policy["Version"], "Statement": new_policy_statements} 73 | -------------------------------------------------------------------------------- /aws_iam_utils/constants.py: -------------------------------------------------------------------------------- 1 | # Access levels as they appear in the raw AWS IAM data returned by get_action_data 2 | # (see https://raw.githubusercontent.com/salesforce/policy_sentry/master/policy_sentry/shared/data/iam-definition.json) # noqa: E501 3 | 4 | READ = "Read" 5 | LIST = "List" 6 | WRITE = "Write" 7 | TAGGING = "Tagging" 8 | PERMISSIONS = "Permissions management" 9 | 10 | ALL_ACCESS_LEVELS = [READ, LIST, WRITE, TAGGING, PERMISSIONS] 11 | 12 | WILDCARD_ARN_TYPE = "*" 13 | -------------------------------------------------------------------------------- /aws_iam_utils/generator.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from policy_sentry.querying.actions import get_actions_for_service 4 | from policy_sentry.querying.actions import get_actions_matching_arn_type 5 | from policy_sentry.querying.actions import get_actions_that_support_wildcard_arns_only 6 | 7 | from aws_iam_utils import checks 8 | from aws_iam_utils.util import create_policy 9 | from aws_iam_utils.util import get_action_data_with_overrides 10 | from aws_iam_utils.util import statement 11 | from aws_iam_utils.constants import READ, LIST, WRITE 12 | 13 | 14 | def generate_read_only_policy_for_service( 15 | service_name: str, use_wildcard_verbs: bool = True 16 | ) -> dict: 17 | """Generates an IAM policy that grants read-only access to all of the 18 | given service.""" 19 | return generate_policy_for_service( 20 | service_name, [LIST, READ], use_wildcard_verbs=use_wildcard_verbs 21 | ) 22 | 23 | 24 | def generate_list_only_policy_for_service( 25 | service_name: str, use_wildcard_verbs: bool = True 26 | ) -> dict: 27 | """Generates an IAM policy that grants list-only access to all of the given 28 | service.""" 29 | return generate_policy_for_service( 30 | service_name, [LIST], use_wildcard_verbs=use_wildcard_verbs 31 | ) 32 | 33 | 34 | def generate_read_write_policy_for_service( 35 | service_name: str, use_wildcard_verbs: bool = True 36 | ) -> dict: 37 | """Generates an IAM policy that grants read-write access to all of the given 38 | service.""" 39 | return generate_policy_for_service( 40 | service_name, [LIST, READ, WRITE], use_wildcard_verbs=use_wildcard_verbs 41 | ) 42 | 43 | 44 | def generate_read_only_policy_for_service_arn_type( 45 | service_name: str, arn_type: str 46 | ) -> dict: 47 | """Generates an IAM policy that grants read-only access to all of the given 48 | service.""" 49 | return generate_policy_for_service_arn_type(service_name, arn_type, [LIST, READ]) 50 | 51 | 52 | def generate_list_only_policy_for_service_arn_type( 53 | service_name: str, arn_type: str 54 | ) -> dict: 55 | """Generates an IAM policy that grants list-only access to all of the given 56 | service.""" 57 | return generate_policy_for_service_arn_type(service_name, arn_type, [LIST]) 58 | 59 | 60 | def generate_read_write_policy_for_service_arn_type( 61 | service_name: str, arn_type: str 62 | ) -> dict: 63 | """Generates an IAM policy that grants read-write access to all of the given 64 | service.""" 65 | return generate_policy_for_service_arn_type( 66 | service_name, arn_type, [LIST, READ, WRITE] 67 | ) 68 | 69 | 70 | def generate_full_policy_for_service(*service_name: str) -> dict: 71 | """Generates an IAM policy that grants full access to all of the given service.""" 72 | return create_policy( 73 | { 74 | "Effect": "Allow", 75 | "Action": [f"{s}:*" for s in service_name], 76 | "Resource": "*", 77 | } 78 | ) 79 | 80 | 81 | def generate_policy_for_service_arn_type( 82 | service_name: str, 83 | arn_type: str, 84 | reqd_access_levels: list[str], 85 | include_service_wide_actions: bool = False, 86 | ) -> dict: 87 | """ 88 | Generates an IAM policy that grants the given level of access to a specific 89 | ARN type within the given AWS service. 90 | 91 | Use `policy_sentry query arn-table --service ` to query ARN types 92 | available for a service. 93 | 94 | If use_wildcard_verbs is True (the default), the generator will try to use 95 | use verb wildcards (e.g. "s3:Get*") rather than full action names to keep 96 | the policy short and readable. 97 | 98 | The generator always verifies that the generated policy only grants permissions 99 | in the given access level. If this check fails when use_wildcard_verbs is 100 | True, the full action list is returned. If the check still fails with the 101 | full action list, an AssertionError is raised, and this indicates an underlying 102 | bug in the policy data driving aws-iam-utils. 103 | 104 | If include_service_wide_actions is True, any actions in the service not linked 105 | to any resource type are also included in the result. This may be needed for 106 | some wildcard actions (e.g. ssm:DescribeParameters, ec2:DescribeFlowLogs) which 107 | are not linked to a specific ARN type in the IAM database. 108 | """ 109 | service_actions = get_actions_matching_arn_type(service_name, arn_type) 110 | 111 | if include_service_wide_actions: 112 | wildcard_arn_actions = get_actions_that_support_wildcard_arns_only(service_name) 113 | service_actions.extend(wildcard_arn_actions) 114 | 115 | # use_wildcard_verbs is False here as it'll always fail (verb-based 116 | # wildcards will always match more than one ARN type) 117 | return __generate_and_validate_policy_from_actions( 118 | service_actions, 119 | service_name, 120 | reqd_access_levels, 121 | use_wildcard_verbs=False, 122 | ) 123 | 124 | 125 | def generate_policy_for_service( 126 | service_name: str, reqd_access_levels: list[str], use_wildcard_verbs: bool = True 127 | ) -> dict: 128 | """ 129 | Generates an IAM policy that grants the given level of access to all of the given 130 | AWS service. 131 | 132 | If use_wildcard_verbs is True (the default), the generator will try to use use verb 133 | wildcards (e.g. "s3:Get*") rather than full action names to keep the policy short 134 | and readable. 135 | 136 | The generator always verifies that the generated policy only grants permissions in 137 | the given access level. If this check fails when use_wildcard_verbs is True, the 138 | full action list is returned. If the check still fails with the full action list, an 139 | AssertionError is raised, and this indicates an underlying bug in the policy data 140 | driving aws-iam-utils. 141 | """ 142 | service_actions = get_actions_for_service(service_name) 143 | 144 | return __generate_and_validate_policy_from_actions( 145 | service_actions, service_name, reqd_access_levels, use_wildcard_verbs 146 | ) 147 | 148 | 149 | def __generate_and_validate_policy_from_actions( 150 | service_actions: list[str], 151 | service_name: str, 152 | reqd_access_levels: list[str], 153 | use_wildcard_verbs: bool, 154 | ) -> dict: 155 | matching_actions = [] 156 | policy = None 157 | 158 | for action in service_actions: 159 | action_service, action_name = action.split(":") 160 | 161 | # iterate through each action and pull out read-only actions 162 | action_output = get_action_data_with_overrides(action_service, action_name) 163 | 164 | if action_output is False: 165 | raise ValueError(f"invalid action: {action_name}") 166 | 167 | for action_output_action in action_output[service_name]: 168 | if ( 169 | action_output_action["access_level"] in reqd_access_levels 170 | and action_output_action["action"] not in matching_actions 171 | ): 172 | matching_actions.append(action_output_action["action"]) 173 | 174 | if use_wildcard_verbs: 175 | # In this mode, we deduce the 'verb' (first word, assuming camel case) of each 176 | # action in the list and try to shorten the list to those verbs, wildcarded. 177 | # This approach makes for way shorter and easier-to-read policies. 178 | wildcarded_matching_actions = [] 179 | 180 | for action in matching_actions: 181 | action_service, action_name = action.split(":") 182 | 183 | parts = list( 184 | filter(lambda x: len(x) > 0, re.split("([A-Z])", action_name, 2)) 185 | ) 186 | 187 | if len(parts) >= 4: 188 | # 'DescribeJobAspectsPolicy' -> ['D', 'escribe', 'J', 'obAspectsPolicy'] 189 | verb = parts[0] + parts[1] 190 | wildcarded_verb = f"{service_name}:{verb}*" 191 | 192 | if ( 193 | "Policy" not in action_name 194 | and "Tagging" not in action_name 195 | and wildcarded_verb not in wildcarded_matching_actions 196 | ): 197 | wildcarded_matching_actions.append(wildcarded_verb) 198 | 199 | else: 200 | wildcarded_matching_actions.append(action) 201 | 202 | policy = create_policy( 203 | statement(actions=wildcarded_matching_actions, resource="*") 204 | ) 205 | 206 | else: 207 | policy = create_policy(statement(actions=matching_actions, resource="*")) 208 | 209 | if use_wildcard_verbs: 210 | # in this mode, check the policy is not too permissive and if so, 211 | # fall back to the full action list 212 | if not checks.policy_has_only_these_access_levels(policy, reqd_access_levels): 213 | policy = create_policy(statement(actions=matching_actions, resource="*")) 214 | 215 | assert checks.policy_has_only_these_access_levels(policy, reqd_access_levels) 216 | 217 | return policy 218 | -------------------------------------------------------------------------------- /aws_iam_utils/policy.py: -------------------------------------------------------------------------------- 1 | from aws_iam_utils.util import extract_policy_permission_items 2 | from aws_iam_utils.combiner import collapse_policy_statements 3 | from aws_iam_utils.policy_permission_item import PolicyPermissionItem 4 | 5 | 6 | def policy_from_dict(policy): 7 | ppis = [PolicyPermissionItem(**x) for x in extract_policy_permission_items(policy)] 8 | 9 | return Policy(version=policy["Version"], ppis=ppis) 10 | 11 | 12 | class Policy: 13 | def __init__(self, version: str, ppis: list[PolicyPermissionItem]): 14 | self.version = version 15 | self.ppis = ppis 16 | 17 | def as_dict(self): 18 | statements = [p.as_statement() for p in self.ppis] 19 | 20 | return collapse_policy_statements( 21 | { 22 | "Version": self.version, 23 | "Statement": statements, 24 | } 25 | ) 26 | 27 | def find_action_ppis(self, action_name): 28 | l_action_name = action_name.lower() 29 | result = [] 30 | for ppi in self.ppis: 31 | if ppi.action.lower() == l_action_name: 32 | result.append(ppi) 33 | 34 | return result 35 | 36 | def add_policy_statements(self, policy): 37 | """Adds statements from the given policy into this policy.""" 38 | self.ppis.extend(policy_from_dict(policy).ppis) 39 | -------------------------------------------------------------------------------- /aws_iam_utils/policy_permission_item.py: -------------------------------------------------------------------------------- 1 | class PolicyPermissionItem: 2 | def __init__(self, effect, action, resource=None, condition=None, principal=None): 3 | self.effect = effect 4 | self.action = action 5 | self.resource = resource 6 | self.condition = condition 7 | self.principal = principal 8 | 9 | def as_statement(self): 10 | result = { 11 | "Effect": self.effect, 12 | "Action": self.action, 13 | } 14 | 15 | if self.resource is not None: 16 | result["Resource"] = self.resource 17 | if self.condition is not None: 18 | result["Condition"] = self.condition 19 | if self.principal is not None: 20 | result["Principal"] = self.principal 21 | 22 | return result 23 | 24 | def __as_dict(self): 25 | return { 26 | "effect": self.effect, 27 | "action": self.action, 28 | "resource": self.resource, 29 | "condition": self.condition, 30 | "principal": self.principal, 31 | } 32 | 33 | def __repr__(self): 34 | return f"PolicyPermissionItem({self.__as_dict()})" 35 | 36 | def __eq__(self, other): 37 | if type(other) is PolicyPermissionItem: 38 | return self.__as_dict() == other.__as_dict() 39 | else: 40 | return self.__as_dict() == other 41 | -------------------------------------------------------------------------------- /aws_iam_utils/simplifier.py: -------------------------------------------------------------------------------- 1 | def simplify_policy(p: dict) -> dict: 2 | """For the given policy, simplify any one-item arrays into straight strings, for 3 | Actions, Principals and Resources.""" 4 | 5 | statements = p["Statement"] 6 | 7 | for statement in statements: 8 | for k in ["Action", "Resource"]: 9 | if k in statement: 10 | if type(statement[k]) is list and len(statement[k]) == 1: 11 | statement[k] = statement[k][0] 12 | 13 | if "Principal" in statement: 14 | principal = statement["Principal"] 15 | 16 | for k in ["AWS", "Service"]: 17 | if k in principal: 18 | if type(principal[k]) is list and len(principal[k]) == 1: 19 | principal[k] = principal[k][0] 20 | 21 | return p 22 | -------------------------------------------------------------------------------- /aws_iam_utils/util.py: -------------------------------------------------------------------------------- 1 | from policy_sentry.querying.actions import get_action_data 2 | from aws_iam_utils.action_data_overrides import ACTION_DATA_OVERRIDES 3 | 4 | 5 | def create_policy(*statements: dict, version: str = "2012-10-17") -> dict: 6 | """Shortcut function to create a policy with the given statements.""" 7 | return {"Version": version, "Statement": list(statements)} 8 | 9 | 10 | def statement( 11 | effect: str = "Allow", 12 | actions: list[str] = [], 13 | resource: str = None, 14 | condition: dict = None, 15 | principal: dict = None, 16 | ) -> dict: 17 | """Shortcut function to create a Statement with the given properties.""" 18 | st = {} 19 | 20 | for k, v in { 21 | "Effect": effect, 22 | "Action": actions, 23 | "Resource": resource, 24 | "Principal": principal, 25 | "Condition": condition, 26 | }.items(): 27 | if v is not None: 28 | st[k] = v 29 | 30 | return st 31 | 32 | 33 | def extract_policy_permission_items( 34 | policy: dict, allow_unsupported: bool = False 35 | ) -> dict: 36 | """ 37 | For every individual permission granted, we build a list of 38 | { permission, resource, condition, principal } ("permission items"). 39 | 40 | The policy is always expanded via expand_policy() first. 41 | 42 | This is useful for comparisons. Currently it does NOT support 43 | NotAction, NotPrincipal, NotResource keys. The presences of 44 | those keys will result in an exception, unless allow_unsupported is True. 45 | """ 46 | 47 | items = [] 48 | 49 | policy_expanded = policy 50 | 51 | for statement in policy_expanded["Statement"]: 52 | if not allow_unsupported: 53 | for k in ["NotAction", "NotPrincipal", "NotResource"]: 54 | if k in statement: 55 | raise ValueError( 56 | f"""Policy key {k} is not supported by 57 | extract_policy_permission_items() and will be ignored. To 58 | ignore this error, call extract_policy_permission_items 59 | with allow_unsupported=True.""" 60 | ) 61 | 62 | for k in ["Action", "Resource"]: 63 | if type(statement.get(k)) is str: # turn into list 64 | statement[k] = [statement[k]] 65 | 66 | effect = statement.get("Effect") 67 | condition = statement.get("Condition") 68 | principal = statement.get("Principal") 69 | 70 | for resource in statement.get("Resource", [None]): 71 | for action in statement["Action"]: 72 | items.append( 73 | { 74 | "effect": effect, 75 | "action": action.lower(), 76 | "resource": resource, 77 | "condition": condition, 78 | "principal": principal, 79 | } 80 | ) 81 | 82 | return items 83 | 84 | 85 | def dedupe_list(lst: list) -> list: 86 | return sorted(set(lst), key=lambda x: lst.index(x)) 87 | 88 | 89 | def dedupe_policy(policy: dict) -> dict: 90 | """Deduplicates all Actions, Principals and Resources in the given 91 | policy.""" 92 | for statement in policy["Statement"]: 93 | for k in ["Action", "Resource"]: 94 | if type(statement.get(k)) is list: 95 | statement[k] = dedupe_list(statement[k]) 96 | 97 | principal = statement.get("Principal") 98 | for k in ["AWS", "Service"]: 99 | if type(principal.get(k)) is list: 100 | principal[k] = dedupe_list(principal[k]) 101 | 102 | return policy 103 | 104 | 105 | def get_action_data_with_overrides(service_name: str, action_name: str) -> dict: 106 | full_action_name = f"{service_name}:{action_name.lower()}" 107 | if full_action_name in ACTION_DATA_OVERRIDES: 108 | return {service_name: [ACTION_DATA_OVERRIDES[full_action_name]]} 109 | 110 | return get_action_data(service_name, action_name) 111 | 112 | 113 | def lowercase_policy(p): 114 | """Returns policy p, but with all actions and effects in lowercase, to 115 | match the behaviour of policyuniverse-expanded policies, so we can easily 116 | compare policies.""" 117 | new_statements = [] 118 | 119 | for statement in p["Statement"]: 120 | new_statement = {} 121 | 122 | # do NOT do Effect lower(), as this breaks policyuniverse 123 | new_statement["Effect"] = statement["Effect"] 124 | 125 | for key in ["Action"]: 126 | if key in statement: 127 | if type(statement[key]) is list: 128 | new_statement[key] = [s.lower() for s in statement[key]] 129 | else: 130 | new_statement[key] = statement[key].lower() 131 | 132 | new_statements.append(new_statement) 133 | 134 | for key in ["Principal", "Condition", "Resource"]: 135 | if key in statement: 136 | new_statement[key] = statement[key] 137 | 138 | return { 139 | "Version": p["Version"], 140 | "Statement": new_statements, 141 | } 142 | 143 | 144 | def create_lowercase_policy(*st): 145 | """Simply wraps create_policy and lowercase_policy, creating a policy with 146 | lowercase actions and effects regardless of the inputs.""" 147 | return lowercase_policy(create_policy(*st)) 148 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from setuptools import setup 3 | from setuptools import find_packages 4 | 5 | # The directory containing this file 6 | HERE = pathlib.Path(__file__).parent 7 | 8 | # The text of the README file 9 | README = (HERE / "README.md").read_text() 10 | 11 | # This call to setup() does all the work 12 | setup( 13 | name="aws-iam-utils", 14 | version="1.8.0", 15 | description="AWS IAM utility library", 16 | long_description=README, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/jtyers/aws-iam-utils", 19 | author="Jonny Tyers", 20 | author_email="jonny@jonnytyers.co.uk", 21 | license="MIT", 22 | classifiers=[ 23 | "License :: OSI Approved :: MIT License", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | ], 28 | packages=find_packages(exclude=("tests",)), 29 | include_package_data=True, 30 | install_requires=[ 31 | "policyuniverse==1.5.0.20220523", 32 | "policy_sentry==0.12.3", 33 | ], 34 | # entry_points={ 35 | # }, 36 | ) 37 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jtyers/aws-iam-utils/8b410d64b2869769cedef008227959128ce9a3c9/tests/__init__.py -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | # https://docs.python-guide.org/writing/structure/ 2 | import os 3 | import sys 4 | 5 | import aws_iam_utils # noqa: F401 6 | 7 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) 8 | -------------------------------------------------------------------------------- /tests/test_action_data_overrides.py: -------------------------------------------------------------------------------- 1 | from policy_sentry.querying.actions import get_action_data 2 | from aws_iam_utils.util import get_action_data_with_overrides 3 | from aws_iam_utils.action_data_overrides import ACTION_DATA_OVERRIDES 4 | 5 | 6 | def test_policy_sentry_actions_out_of_date(): 7 | # verify that the actions we think are missing, are missing 8 | for action in ACTION_DATA_OVERRIDES: 9 | service_name, action_name = action.split(":") 10 | assert get_action_data(service_name, action_name) is False 11 | 12 | 13 | def test_get_action_data_with_overrides(): 14 | for action in ACTION_DATA_OVERRIDES: 15 | # action = 'lambda:getfunctionurlconfig' 16 | service_name, action_name = action.split(":") 17 | 18 | result = get_action_data_with_overrides(service_name, action_name) 19 | 20 | assert type(result) is dict 21 | assert result == {service_name: [ACTION_DATA_OVERRIDES[action]]} 22 | 23 | result_action = result[service_name][0] 24 | assert result_action["action"].lower() == action 25 | -------------------------------------------------------------------------------- /tests/test_combiner_collapse_policy_statements.py: -------------------------------------------------------------------------------- 1 | from .context import aws_iam_utils 2 | 3 | from aws_iam_utils.util import create_policy 4 | from aws_iam_utils.util import create_lowercase_policy 5 | from aws_iam_utils.util import statement 6 | 7 | 8 | def s3_arn(b): 9 | return f"arn:aws:s3:::{b}" 10 | 11 | 12 | def test_collapse_policy_actions(): 13 | p = create_policy( 14 | statement( 15 | actions=[ 16 | "s3:PutObject", 17 | ], 18 | resource="*", 19 | ), 20 | statement( 21 | actions=[ 22 | "s3:GetObject", 23 | ], 24 | resource="*", 25 | ), 26 | ) 27 | 28 | assert aws_iam_utils.combiner.collapse_policy_statements( 29 | p 30 | ) == create_lowercase_policy( 31 | { 32 | "Effect": "Allow", 33 | "Action": [ 34 | "s3:putobject", 35 | "s3:getobject", 36 | ], 37 | "Resource": "*", 38 | } 39 | ) 40 | 41 | 42 | def test_collapse_policy_action_lists(): 43 | p = create_policy( 44 | statement(actions=["s3:PutObject", "s3:PutBucketPolicy"], resource="*"), 45 | statement( 46 | actions=["s3:GetObject", "s3:GetObjectAcl", "s3:ListBucket"], resource="*" 47 | ), 48 | ) 49 | 50 | assert aws_iam_utils.combiner.collapse_policy_statements( 51 | p 52 | ) == create_lowercase_policy( 53 | { 54 | "Effect": "Allow", 55 | "Action": [ 56 | "s3:PutObject", 57 | "s3:PutBucketPolicy", 58 | "s3:GetObject", 59 | "s3:GetObjectAcl", 60 | "s3:ListBucket", 61 | ], 62 | "Resource": "*", 63 | } 64 | ) 65 | 66 | 67 | def test_collapse_policy_action_lists_in_multiple_policies(): 68 | pp = [ 69 | create_policy( 70 | statement(actions=["s3:PutObject", "s3:PutBucketPolicy"], resource="*"), 71 | statement( 72 | actions=["s3:GetObject", "s3:GetObjectAcl", "s3:ListBucket"], 73 | resource="*", 74 | ), 75 | ), 76 | create_policy( 77 | statement( 78 | actions=["s3:PutObjectVersion", "s3:PutObjectLock"], resource="*" 79 | ), 80 | ), 81 | create_policy( 82 | statement(actions=["s3:PutLegalHold", "s3:PutObjectAcl"], resource="*"), 83 | statement( 84 | actions=["s3:ListAllMyBuckets", "s3:GetBucketTagging"], resource="*" 85 | ), 86 | ), 87 | ] 88 | 89 | assert aws_iam_utils.combiner.collapse_policy_statements( 90 | *pp 91 | ) == create_lowercase_policy( 92 | { 93 | "Effect": "Allow", 94 | "Action": [ 95 | "s3:PutObject", 96 | "s3:PutBucketPolicy", 97 | "s3:GetObject", 98 | "s3:GetObjectAcl", 99 | "s3:ListBucket", 100 | "s3:PutObjectVersion", 101 | "s3:PutObjectLock", 102 | "s3:PutLegalHold", 103 | "s3:PutObjectAcl", 104 | "s3:ListAllMyBuckets", 105 | "s3:GetBucketTagging", 106 | ], 107 | "Resource": "*", 108 | } 109 | ) 110 | 111 | 112 | def test_collapse_policy_action_lists_in_multiple_policies_with_duplicates(): 113 | pp = [ 114 | create_policy( 115 | statement(actions=["s3:PutObject", "s3:PutBucketPolicy"], resource="*"), 116 | statement( 117 | actions=["s3:GetObject", "s3:GetObjectAcl", "s3:ListBucket"], 118 | resource="*", 119 | ), 120 | ), 121 | create_policy( 122 | statement( 123 | actions=["s3:PutObjectVersion", "s3:PutObjectLock"], resource="*" 124 | ), 125 | statement(actions=["s3:PutObject", "s3:GetObjectAcl"], resource="*"), 126 | ), 127 | create_policy( 128 | statement(actions=["s3:PutLegalHold", "s3:PutObjectAcl"], resource="*"), 129 | statement( 130 | actions=["s3:ListAllMyBuckets", "s3:GetBucketTagging", "s3:PutObject"], 131 | resource="*", 132 | ), 133 | ), 134 | ] 135 | 136 | assert aws_iam_utils.combiner.collapse_policy_statements( 137 | *pp 138 | ) == create_lowercase_policy( 139 | { 140 | "Effect": "Allow", 141 | "Action": [ 142 | "s3:PutObject", 143 | "s3:PutBucketPolicy", 144 | "s3:GetObject", 145 | "s3:GetObjectAcl", 146 | "s3:ListBucket", 147 | "s3:PutObjectVersion", 148 | "s3:PutObjectLock", 149 | "s3:PutLegalHold", 150 | "s3:PutObjectAcl", 151 | "s3:ListAllMyBuckets", 152 | "s3:GetBucketTagging", 153 | ], 154 | "Resource": "*", 155 | } 156 | ) 157 | 158 | 159 | def test_collapse_policy_actions_across_resources(): 160 | p = create_policy( 161 | statement( 162 | actions=[ 163 | "s3:PutObject", 164 | ], 165 | resource="*", 166 | ), 167 | statement( 168 | actions=[ 169 | "s3:GetObject", 170 | ], 171 | resource="*", 172 | ), 173 | statement( 174 | actions=[ 175 | "s3:PutObjectVersion", 176 | ], 177 | resource=s3_arn("b1"), 178 | ), 179 | statement( 180 | actions=[ 181 | "s3:ListBucket", 182 | ], 183 | resource=s3_arn("b1"), 184 | ), 185 | ) 186 | 187 | assert aws_iam_utils.combiner.collapse_policy_statements( 188 | p 189 | ) == create_lowercase_policy( 190 | { 191 | "Effect": "Allow", 192 | "Action": [ 193 | "s3:putobject", 194 | "s3:getobject", 195 | ], 196 | "Resource": "*", 197 | }, 198 | { 199 | "Effect": "Allow", 200 | "Action": [ 201 | "s3:putobjectversion", 202 | "s3:listbucket", 203 | ], 204 | "Resource": s3_arn("b1"), 205 | }, 206 | ) 207 | 208 | 209 | def test_collapse_policy_actions_across_conditions_and_resources(): 210 | p = create_policy( 211 | statement( 212 | actions=[ 213 | "s3:PutObject", 214 | ], 215 | resource="*", 216 | condition={"StringEquals": {"foo": "bar"}}, 217 | ), 218 | statement( 219 | actions=[ 220 | "s3:GetObject", 221 | ], 222 | resource="*", 223 | ), 224 | statement( 225 | actions=[ 226 | "s3:PutObjectVersion", 227 | ], 228 | resource=s3_arn("b1"), 229 | ), 230 | statement( 231 | actions=[ 232 | "s3:ListBucket", 233 | ], 234 | resource=s3_arn("b1"), 235 | ), 236 | ) 237 | 238 | assert aws_iam_utils.combiner.collapse_policy_statements( 239 | p 240 | ) == create_lowercase_policy( 241 | { 242 | "Effect": "Allow", 243 | "Action": [ 244 | "s3:putobject", 245 | ], 246 | "Resource": "*", 247 | "Condition": {"StringEquals": {"foo": "bar"}}, 248 | }, 249 | { 250 | "Effect": "Allow", 251 | "Action": [ 252 | "s3:getobject", 253 | ], 254 | "Resource": "*", 255 | }, 256 | { 257 | "Effect": "Allow", 258 | "Action": [ 259 | "s3:putobjectversion", 260 | "s3:listbucket", 261 | ], 262 | "Resource": s3_arn("b1"), 263 | }, 264 | ) 265 | 266 | 267 | def test_collapse_policy_actions_across_principals(): 268 | p = create_policy( 269 | statement(actions="s3:PutObject", principal={"AWS": "foo"}), 270 | statement(actions="s3:PutObjectVersion", principal={"AWS": "bar"}), 271 | statement(actions="s3:GetObject", principal={"AWS": "foo"}), 272 | statement(actions="s3:ListBucket", principal={"AWS": "bar"}), 273 | ) 274 | 275 | assert aws_iam_utils.combiner.collapse_policy_statements( 276 | p 277 | ) == create_lowercase_policy( 278 | { 279 | "Effect": "Allow", 280 | "Action": [ 281 | "s3:putobject", 282 | "s3:getobject", 283 | ], 284 | "Principal": {"AWS": "foo"}, 285 | }, 286 | { 287 | "Effect": "Allow", 288 | "Action": [ 289 | "s3:putobjectversion", 290 | "s3:listbucket", 291 | ], 292 | "Principal": {"AWS": "bar"}, 293 | }, 294 | ) 295 | 296 | 297 | def test_collapse_policy_actions_across_principals_and_effects(): 298 | p = create_policy( 299 | statement(actions="s3:PutObject", principal={"AWS": "foo"}), 300 | statement(actions="s3:PutObjectVersion", principal={"AWS": "bar"}), 301 | statement(actions="s3:GetObject", principal={"AWS": "foo"}), 302 | statement(actions="s3:ListBucket", principal={"AWS": "bar"}), 303 | statement(actions="s3:GetObject", principal={"AWS": "foo"}, effect="Deny"), 304 | statement(actions="s3:ListBucket", principal={"AWS": "foo"}, effect="Deny"), 305 | ) 306 | 307 | assert aws_iam_utils.combiner.collapse_policy_statements( 308 | p 309 | ) == create_lowercase_policy( 310 | { 311 | "Effect": "Allow", 312 | "Action": [ 313 | "s3:putobject", 314 | "s3:getobject", 315 | ], 316 | "Principal": {"AWS": "foo"}, 317 | }, 318 | { 319 | "Effect": "Allow", 320 | "Action": [ 321 | "s3:putobjectversion", 322 | "s3:listbucket", 323 | ], 324 | "Principal": {"AWS": "bar"}, 325 | }, 326 | { 327 | "Effect": "Deny", 328 | "Action": [ 329 | "s3:getobject", 330 | "s3:listbucket", 331 | ], 332 | "Principal": {"AWS": "foo"}, 333 | }, 334 | ) 335 | -------------------------------------------------------------------------------- /tests/test_combiner_combine_policy_statements.py: -------------------------------------------------------------------------------- 1 | from .context import aws_iam_utils 2 | 3 | from aws_iam_utils.util import create_policy 4 | from aws_iam_utils.util import statement 5 | 6 | 7 | def s3_arn(b): 8 | return f"arn:aws:s3:::{b}" 9 | 10 | 11 | def test_combine_nothing_should_return_empty_policy(): 12 | assert aws_iam_utils.combiner.combine_policy_statements() == create_policy() 13 | 14 | 15 | def test_combine_empty_policy_should_return_empty_policy_with_same_version(): 16 | p = create_policy(version="2008-01-01") 17 | 18 | assert aws_iam_utils.combiner.combine_policy_statements(p) == create_policy( 19 | version="2008-01-01" 20 | ) 21 | 22 | 23 | def test_combine_single_policy_should_be_same_result(): 24 | p = create_policy( 25 | statement( 26 | actions=[ 27 | "s3:PutObject", 28 | ], 29 | resource="*", 30 | ), 31 | statement( 32 | actions=[ 33 | "s3:GetObject", 34 | ], 35 | resource="*", 36 | ), 37 | ) 38 | 39 | assert aws_iam_utils.combiner.combine_policy_statements(p) == create_policy( 40 | statement( 41 | actions=[ 42 | "s3:PutObject", 43 | ], 44 | resource="*", 45 | ), 46 | statement( 47 | actions=[ 48 | "s3:GetObject", 49 | ], 50 | resource="*", 51 | ), 52 | ) 53 | 54 | 55 | def test_combine_two_simple_policies(): 56 | p = create_policy( 57 | statement( 58 | actions=[ 59 | "s3:PutObject", 60 | ], 61 | resource="*", 62 | ), 63 | statement( 64 | actions=[ 65 | "s3:GetObject", 66 | ], 67 | resource="*", 68 | ), 69 | ) 70 | 71 | p2 = create_policy( 72 | statement(actions=["s3:PutObject", "s3:ListBucket"], resource="foo"), 73 | ) 74 | 75 | assert aws_iam_utils.combiner.combine_policy_statements(p, p2) == create_policy( 76 | statement( 77 | actions=[ 78 | "s3:PutObject", 79 | ], 80 | resource="*", 81 | ), 82 | statement( 83 | actions=[ 84 | "s3:GetObject", 85 | ], 86 | resource="*", 87 | ), 88 | statement(actions=["s3:PutObject", "s3:ListBucket"], resource="foo"), 89 | ) 90 | 91 | 92 | def test_combine_many_policies_with_various_keys(): 93 | pp = [ 94 | create_policy( 95 | statement( 96 | actions=[ 97 | "s3:PutObject", 98 | ], 99 | resource="*", 100 | ), 101 | statement( 102 | actions=[ 103 | "s3:GetObject", 104 | ], 105 | resource="*", 106 | ), 107 | ), 108 | create_policy( 109 | statement(actions=["s3:PutObject", "s3:ListBucket"], resource="foo"), 110 | ), 111 | create_policy( 112 | statement( 113 | actions=["s3:GetObjectVersion", "s3:GetObjectAcl"], 114 | resource="bar", 115 | condition={"StringEquals": {"foo": "bar"}}, 116 | ), 117 | ), 118 | create_policy( 119 | statement(actions="s3:GetObjectLock", principal={"Service": "dotdot"}), 120 | ), 121 | create_policy( 122 | statement( 123 | effect="Deny", 124 | actions="s3:PutBucketPolicy", 125 | principal={"Service": "ec2"}, 126 | resource=["bat", "baz"], 127 | ), 128 | statement( 129 | effect="Deny", 130 | actions="s3:PutBucketPolicy", 131 | condition={"StringNotEquals": {"service": "ec2"}}, 132 | resource=["bat", "baz"], 133 | ), 134 | ), 135 | ] 136 | 137 | assert aws_iam_utils.combiner.combine_policy_statements(*pp) == create_policy( 138 | statement( 139 | actions=[ 140 | "s3:PutObject", 141 | ], 142 | resource="*", 143 | ), 144 | statement( 145 | actions=[ 146 | "s3:GetObject", 147 | ], 148 | resource="*", 149 | ), 150 | statement(actions=["s3:PutObject", "s3:ListBucket"], resource="foo"), 151 | statement( 152 | actions=["s3:GetObjectVersion", "s3:GetObjectAcl"], 153 | resource="bar", 154 | condition={"StringEquals": {"foo": "bar"}}, 155 | ), 156 | statement(actions="s3:GetObjectLock", principal={"Service": "dotdot"}), 157 | statement( 158 | effect="Deny", 159 | actions="s3:PutBucketPolicy", 160 | principal={"Service": "ec2"}, 161 | resource=["bat", "baz"], 162 | ), 163 | statement( 164 | effect="Deny", 165 | actions="s3:PutBucketPolicy", 166 | condition={"StringNotEquals": {"service": "ec2"}}, 167 | resource=["bat", "baz"], 168 | ), 169 | ) 170 | -------------------------------------------------------------------------------- /tests/test_dedupe_policy.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from aws_iam_utils.util import create_policy 4 | from aws_iam_utils.util import statement 5 | from aws_iam_utils.util import dedupe_policy 6 | 7 | 8 | def test_dedupe_policy_actions(): 9 | p = create_policy( 10 | statement( 11 | actions=["s3:listbucket", "s3:createbucket", "s3:createbucket"], 12 | resource=["foo"], 13 | principal={"AWS": ["bar"]}, 14 | ), 15 | ) 16 | 17 | assert dedupe_policy(copy.deepcopy(p)) == create_policy( 18 | statement( 19 | actions=["s3:listbucket", "s3:createbucket"], 20 | resource=["foo"], 21 | principal={"AWS": ["bar"]}, 22 | ), 23 | ) 24 | 25 | 26 | def test_dedupe_policy_resources(): 27 | p = create_policy( 28 | statement( 29 | actions=["s3:listbucket", "s3:createbucket"], 30 | resource=["foo", "bar", "foo"], 31 | principal={"AWS": ["bar"]}, 32 | ), 33 | ) 34 | 35 | assert dedupe_policy(copy.deepcopy(p)) == create_policy( 36 | statement( 37 | actions=["s3:listbucket", "s3:createbucket"], 38 | resource=["foo", "bar"], 39 | principal={"AWS": ["bar"]}, 40 | ), 41 | ) 42 | 43 | 44 | def test_dedupe_policy_aws_principals(): 45 | p = create_policy( 46 | statement( 47 | actions=["s3:listbucket", "s3:createbucket"], 48 | resource=["foo"], 49 | principal={"AWS": ["bar", "foo", "bar"]}, 50 | ), 51 | ) 52 | 53 | assert dedupe_policy(copy.deepcopy(p)) == create_policy( 54 | statement( 55 | actions=["s3:listbucket", "s3:createbucket"], 56 | resource=["foo"], 57 | principal={"AWS": ["bar", "foo"]}, 58 | ), 59 | ) 60 | 61 | 62 | def test_dedupe_policy_service_principals(): 63 | p = create_policy( 64 | statement( 65 | actions=["s3:listbucket", "s3:createbucket"], 66 | resource=["foo"], 67 | principal={"Service": ["bar", "foo", "bar"]}, 68 | ), 69 | ) 70 | 71 | assert dedupe_policy(copy.deepcopy(p)) == create_policy( 72 | statement( 73 | actions=["s3:listbucket", "s3:createbucket"], 74 | resource=["foo"], 75 | principal={"Service": ["bar", "foo"]}, 76 | ), 77 | ) 78 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | from policyuniverse.expander_minimizer import expand_policy 2 | 3 | from .context import aws_iam_utils 4 | from aws_iam_utils.checks import policy_has_only_these_access_levels 5 | from aws_iam_utils.util import create_policy 6 | from aws_iam_utils.util import statement 7 | from aws_iam_utils.constants import READ, LIST, WILDCARD_ARN_TYPE 8 | 9 | 10 | def test_generate_read_only_policy(): 11 | p = aws_iam_utils.generator.generate_read_only_policy_for_service("s3") 12 | 13 | assert aws_iam_utils.checks.is_read_only_policy(p) 14 | assert p == create_policy( 15 | statement( 16 | actions=[ 17 | "s3:Describe*", 18 | "s3:Get*", 19 | "s3:List*", 20 | ], 21 | resource="*", 22 | ) 23 | ) 24 | 25 | 26 | def test_generate_read_write_policy(): 27 | p = aws_iam_utils.generator.generate_read_write_policy_for_service("s3") 28 | 29 | for st in p["Statement"]: 30 | for action in st["Action"]: 31 | assert action.startswith("s3:") 32 | 33 | assert aws_iam_utils.checks.is_read_write_policy(p) 34 | 35 | 36 | def test_generate_list_only_policy(): 37 | p = aws_iam_utils.generator.generate_list_only_policy_for_service("s3") 38 | 39 | assert aws_iam_utils.checks.is_list_only_policy(p) 40 | assert p == create_policy(statement(actions=["s3:List*"], resource="*")) 41 | 42 | 43 | def test_generate_full_policy(): 44 | p = aws_iam_utils.generator.generate_full_policy_for_service("s3") 45 | 46 | assert p == create_policy(statement(actions=["s3:*"], resource="*")) 47 | 48 | 49 | def test_generate_read_only_policy_for_arn_type(): 50 | p = aws_iam_utils.generator.generate_read_only_policy_for_service_arn_type( 51 | "s3", "bucket" 52 | ) 53 | 54 | assert aws_iam_utils.checks.is_read_only_policy(p) 55 | assert aws_iam_utils.checks.policy_has_only_these_arn_types(p, "s3", ["bucket"]) 56 | 57 | 58 | def test_generate_read_write_policy_for_arn_type(): 59 | p = aws_iam_utils.generator.generate_read_write_policy_for_service_arn_type( 60 | "s3", "bucket" 61 | ) 62 | 63 | assert aws_iam_utils.checks.is_read_write_policy(p) 64 | assert aws_iam_utils.checks.policy_has_only_these_arn_types(p, "s3", ["bucket"]) 65 | 66 | 67 | def test_generate_list_only_policy_for_arn_type(): 68 | p = aws_iam_utils.generator.generate_list_only_policy_for_service_arn_type( 69 | "s3", "bucket" 70 | ) 71 | 72 | assert aws_iam_utils.checks.is_list_only_policy(p) 73 | assert aws_iam_utils.checks.policy_has_only_these_arn_types(p, "s3", ["bucket"]) 74 | 75 | 76 | def test_generate_list_only_policy_for_arn_type_not_matching(): 77 | p = aws_iam_utils.generator.generate_list_only_policy_for_service_arn_type( 78 | "s3", "bucket" 79 | ) 80 | 81 | assert aws_iam_utils.checks.is_list_only_policy(p) 82 | assert not aws_iam_utils.checks.policy_has_only_these_arn_types(p, "s3", ["object"]) 83 | 84 | 85 | def test_generate_list_only_policy_for_wildcard_arn_type(): 86 | p = aws_iam_utils.generator.generate_list_only_policy_for_service_arn_type( 87 | "s3", WILDCARD_ARN_TYPE 88 | ) 89 | 90 | assert aws_iam_utils.checks.is_list_only_policy(p) 91 | assert aws_iam_utils.checks.policy_has_only_these_arn_types( 92 | p, "s3", [WILDCARD_ARN_TYPE] 93 | ) 94 | 95 | 96 | def test_generate_policy_for_service_uses_action_data_overrides(): 97 | # events:describeendpoint is a known action that needs an override, 98 | # so generate a policy that contains it 99 | p = aws_iam_utils.generator.generate_policy_for_service("events", [LIST, READ]) 100 | assert "events:describeendpoint" in expand_policy(p)["Statement"][0]["Action"] 101 | 102 | 103 | def test_generate_policy_for_service_includes_wildcard_actions(): 104 | # ec2:DescribeFlowLogs and ssm:DescribeParameters are both actions 105 | # for wildcard resources (i.e. you grant Resource = "*") 106 | p = aws_iam_utils.generator.generate_policy_for_service("ssm", [LIST, READ]) 107 | assert "ssm:describeparameters" in expand_policy(p)["Statement"][0]["Action"] 108 | 109 | p = aws_iam_utils.generator.generate_policy_for_service("ec2", [LIST, READ]) 110 | assert "ec2:describeflowlogs" in expand_policy(p)["Statement"][0]["Action"] 111 | 112 | 113 | def test_generate_policy_for_service_arn_type_includes_wildcard_actions(): 114 | # ec2:DescribeFlowLogs and ssm:DescribeParameters are both actions 115 | # for wildcard resources (i.e. you grant Resource = "*") 116 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 117 | "ssm", "parameter", [LIST, READ], include_service_wide_actions=True 118 | ) 119 | assert "ssm:describeparameters" in expand_policy(p)["Statement"][0]["Action"] 120 | 121 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 122 | "ec2", "vpc-flow-log", [LIST, READ], include_service_wide_actions=True 123 | ) 124 | assert "ec2:describeflowlogs" in expand_policy(p)["Statement"][0]["Action"] 125 | 126 | 127 | def test_generate_policy_for_service_arn_type_excludes_wildcard_actions(): 128 | # ec2:DescribeFlowLogs and ssm:DescribeParameters are both actions 129 | # for wildcard resources (i.e. you grant Resource = "*") 130 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 131 | "ssm", "parameter", [LIST, READ], include_service_wide_actions=False 132 | ) 133 | assert "ssm:describeparameters" not in expand_policy(p)["Statement"][0]["Action"] 134 | 135 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 136 | "ec2", "vpc-flow-log", [LIST, READ], include_service_wide_actions=False 137 | ) 138 | assert "ec2:describeflowlogs" not in expand_policy(p)["Statement"][0]["Action"] 139 | 140 | 141 | def test_generate_policy_with_wildcard_actions_doesnt_overgrant(): 142 | # just to be sure - double check we don't add extra permissions 143 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 144 | "ssm", "parameter", [LIST, READ], include_service_wide_actions=True 145 | ) 146 | assert "ssm:describeparameters" in expand_policy(p)["Statement"][0]["Action"] 147 | 148 | p = aws_iam_utils.generator.generate_policy_for_service_arn_type( 149 | "ec2", "vpc-flow-log", [LIST, READ], include_service_wide_actions=True 150 | ) 151 | 152 | assert policy_has_only_these_access_levels(p, [LIST, READ]) 153 | -------------------------------------------------------------------------------- /tests/test_is_list_only_policy.py: -------------------------------------------------------------------------------- 1 | from policyuniverse.expander_minimizer import minimize_policy 2 | from .context import aws_iam_utils 3 | 4 | 5 | def create_policy(*statements): 6 | return {"Version": "2012-10-17", "Statement": statements} 7 | 8 | 9 | def test_policy_is_list_only_with_single_list_only_op(): 10 | p = create_policy( 11 | { 12 | "Effect": "Allow", 13 | "Action": "s3:ListBucketVersions", 14 | "Resource": "*", 15 | } 16 | ) 17 | 18 | assert aws_iam_utils.checks.is_list_only_policy(p) 19 | 20 | 21 | def test_policy_is_list_only_with_list_only_ops(): 22 | p = create_policy( 23 | { 24 | "Effect": "Allow", 25 | "Action": [ 26 | "s3:ListBucket", 27 | "s3:ListBucketVersions", 28 | ], 29 | "Resource": "*", 30 | } 31 | ) 32 | 33 | assert aws_iam_utils.checks.is_list_only_policy(p) 34 | 35 | 36 | def test_policy_is_list_only_with_list_only_ops_via_wildcards(): 37 | p = minimize_policy( 38 | create_policy( 39 | { 40 | "Effect": "Allow", 41 | "Action": [ 42 | "s3:ListBucket*", 43 | ], 44 | "Resource": "*", 45 | } 46 | ) 47 | ) 48 | 49 | assert aws_iam_utils.checks.is_list_only_policy(p) 50 | 51 | 52 | def test_policy_is_not_list_only_with_read_ops(): 53 | p = create_policy( 54 | { 55 | "Effect": "Allow", 56 | "Action": [ 57 | "s3:ListBucket", 58 | "s3:ListBucketVersions", 59 | "s3:GetObjectVersion", 60 | ], 61 | "Resource": "*", 62 | } 63 | ) 64 | 65 | assert not aws_iam_utils.checks.is_list_only_policy(p) 66 | 67 | 68 | def test_policy_is_not_list_only_with_write_ops(): 69 | p = create_policy( 70 | { 71 | "Effect": "Allow", 72 | "Action": [ 73 | "s3:ListBucket", 74 | "s3:ListBucketVersions", 75 | "s3:PutObject", 76 | ], 77 | "Resource": "*", 78 | } 79 | ) 80 | 81 | assert not aws_iam_utils.checks.is_list_only_policy(p) 82 | 83 | 84 | def test_policy_is_not_list_only_with_permmgmt_ops(): 85 | p = create_policy( 86 | { 87 | "Effect": "Allow", 88 | "Action": [ 89 | "s3:ListBucket", 90 | "s3:ListBucketVersions", 91 | "s3:PutBucketPolicy", 92 | ], 93 | "Resource": "*", 94 | } 95 | ) 96 | 97 | assert not aws_iam_utils.checks.is_list_only_policy(p) 98 | 99 | 100 | def test_policy_is_not_list_only_with_tagging_ops(): 101 | p = create_policy( 102 | { 103 | "Effect": "Allow", 104 | "Action": [ 105 | "s3:ListBucket", 106 | "s3:ListBucketVersions", 107 | "s3:PutBucketTagging", 108 | ], 109 | "Resource": "*", 110 | } 111 | ) 112 | 113 | assert not aws_iam_utils.checks.is_list_only_policy(p) 114 | -------------------------------------------------------------------------------- /tests/test_is_read_only_policy.py: -------------------------------------------------------------------------------- 1 | from policyuniverse.expander_minimizer import minimize_policy 2 | 3 | from aws_iam_utils.checks import is_read_only_policy 4 | from aws_iam_utils.checks import policy_has_only_these_access_levels 5 | from aws_iam_utils.constants import READ 6 | from aws_iam_utils.util import create_policy 7 | from aws_iam_utils.util import statement 8 | 9 | 10 | def test_policy_is_read_only_with_single_read_only_op(): 11 | p = create_policy( 12 | { 13 | "Effect": "Allow", 14 | "Action": "s3:GetObject", 15 | "Resource": "*", 16 | } 17 | ) 18 | 19 | assert is_read_only_policy(p) 20 | 21 | 22 | def test_policy_is_read_only_with_list_only_ops(): 23 | p = create_policy( 24 | { 25 | "Effect": "Allow", 26 | "Action": [ 27 | "s3:ListBucket", 28 | "s3:ListBucketVersions", 29 | ], 30 | "Resource": "*", 31 | } 32 | ) 33 | 34 | assert is_read_only_policy(p) 35 | 36 | 37 | def test_policy_is_read_only_with_read_only_ops(): 38 | p = create_policy( 39 | { 40 | "Effect": "Allow", 41 | "Action": [ 42 | "s3:GetObjectVersion", 43 | "s3:GetObjectVersionAcl", 44 | "s3:GetObjectVersionAttributes", 45 | "s3:GetObjectVersionTagging", 46 | "s3:GetObjectVersionForReplication", 47 | "s3:GetObjectVersionTorrent", 48 | "s3:GetObject", 49 | ], 50 | "Resource": "*", 51 | } 52 | ) 53 | 54 | assert is_read_only_policy(p) 55 | 56 | 57 | def test_policy_is_read_only_with_read_only_ops_via_wildcards(): 58 | p = minimize_policy( 59 | create_policy( 60 | { 61 | "Effect": "Allow", 62 | "Action": [ 63 | "s3:GetObjectVersion", 64 | "s3:GetObjectVersionAcl", 65 | "s3:GetObjectVersionAttributes", 66 | "s3:GetObjectVersionTagging", 67 | "s3:GetObjectVersionForReplication", 68 | "s3:GetObjectVersionTorrent", 69 | "s3:GetObject", 70 | ], 71 | "Resource": "*", 72 | } 73 | ) 74 | ) 75 | 76 | assert is_read_only_policy(p) 77 | 78 | 79 | def test_policy_is_not_read_only_with_write_ops(): 80 | p = create_policy( 81 | { 82 | "Effect": "Allow", 83 | "Action": [ 84 | "s3:GetObjectVersion", 85 | "s3:GetObjectVersionAcl", 86 | "s3:GetObjectVersionAttributes", 87 | "s3:GetObjectVersionTagging", 88 | "s3:GetObjectVersionForReplication", 89 | "s3:GetObjectVersionTorrent", 90 | "s3:GetObject", 91 | "s3:PutObject", 92 | ], 93 | "Resource": "*", 94 | } 95 | ) 96 | 97 | assert not is_read_only_policy(p) 98 | 99 | 100 | def test_policy_is_not_read_only_with_permmgmt_ops(): 101 | p = create_policy( 102 | { 103 | "Effect": "Allow", 104 | "Action": [ 105 | "s3:GetObjectVersion", 106 | "s3:GetObjectVersionAcl", 107 | "s3:GetObjectVersionAttributes", 108 | "s3:GetObjectVersionTagging", 109 | "s3:GetObjectVersionForReplication", 110 | "s3:GetObjectVersionTorrent", 111 | "s3:GetObject", 112 | "s3:PutBucketPolicy", 113 | ], 114 | "Resource": "*", 115 | } 116 | ) 117 | 118 | assert not is_read_only_policy(p) 119 | 120 | 121 | def test_policy_is_not_read_only_with_tagging_ops(): 122 | p = create_policy( 123 | { 124 | "Effect": "Allow", 125 | "Action": [ 126 | "s3:GetObjectVersion", 127 | "s3:GetObjectVersionAcl", 128 | "s3:GetObjectVersionAttributes", 129 | "s3:GetObjectVersionTagging", 130 | "s3:GetObjectVersionForReplication", 131 | "s3:GetObjectVersionTorrent", 132 | "s3:GetObject", 133 | "s3:PutBucketTagging", 134 | ], 135 | "Resource": "*", 136 | } 137 | ) 138 | 139 | assert not is_read_only_policy(p) 140 | 141 | 142 | def test_generate_policy_for_service_uses_action_data_overrides(): 143 | # events:describeendpoint is a known action that needs an override, 144 | # so generate a policy that contains it 145 | p = create_policy(statement(actions=["events:describe*"])) 146 | assert policy_has_only_these_access_levels(p, [READ]) 147 | -------------------------------------------------------------------------------- /tests/test_is_read_write_policy.py: -------------------------------------------------------------------------------- 1 | from policyuniverse.expander_minimizer import minimize_policy 2 | from .context import aws_iam_utils 3 | 4 | 5 | def create_policy(*statements): 6 | return {"Version": "2012-10-17", "Statement": statements} 7 | 8 | 9 | def test_policy_is_read_write_with_single_read_write_op(): 10 | p = create_policy( 11 | { 12 | "Effect": "Allow", 13 | "Action": "s3:ListBucketVersions", 14 | "Resource": "*", 15 | } 16 | ) 17 | 18 | assert aws_iam_utils.checks.is_read_write_policy(p) 19 | 20 | 21 | def test_policy_is_read_write_with_list_only_ops(): 22 | p = create_policy( 23 | { 24 | "Effect": "Allow", 25 | "Action": [ 26 | "s3:ListBucket", 27 | "s3:ListBucketVersions", 28 | ], 29 | "Resource": "*", 30 | } 31 | ) 32 | 33 | assert aws_iam_utils.checks.is_list_only_policy(p) 34 | 35 | 36 | def test_policy_is_read_write_with_read_write_ops_via_wildcards(): 37 | p = minimize_policy( 38 | create_policy( 39 | { 40 | "Effect": "Allow", 41 | "Action": [ 42 | "s3:PutBucketVer*", 43 | ], 44 | "Resource": "*", 45 | } 46 | ) 47 | ) 48 | 49 | assert aws_iam_utils.checks.is_read_write_policy(p) 50 | 51 | 52 | def test_policy_is_read_write_with_read_ops(): 53 | p = create_policy( 54 | { 55 | "Effect": "Allow", 56 | "Action": [ 57 | "s3:ListBucket", 58 | "s3:ListBucketVersions", 59 | "s3:GetObjectVersion", 60 | ], 61 | "Resource": "*", 62 | } 63 | ) 64 | 65 | assert aws_iam_utils.checks.is_read_write_policy(p) 66 | 67 | 68 | def test_policy_is_read_write_with_write_ops(): 69 | p = create_policy( 70 | { 71 | "Effect": "Allow", 72 | "Action": [ 73 | "s3:ListBucket", 74 | "s3:ListBucketVersions", 75 | "s3:PutObject", 76 | ], 77 | "Resource": "*", 78 | } 79 | ) 80 | 81 | assert aws_iam_utils.checks.is_read_write_policy(p) 82 | 83 | 84 | def test_policy_is_not_read_write_with_permmgmt_ops(): 85 | p = create_policy( 86 | { 87 | "Effect": "Allow", 88 | "Action": [ 89 | "s3:PutObject", 90 | "s3:PutBucketPolicy", 91 | ], 92 | "Resource": "*", 93 | } 94 | ) 95 | 96 | assert not aws_iam_utils.checks.is_read_write_policy(p) 97 | 98 | 99 | def test_policy_is_not_read_write_with_tagging_ops(): 100 | p = create_policy( 101 | { 102 | "Effect": "Allow", 103 | "Action": [ 104 | "s3:PutObject", 105 | "s3:PutBucketTagging", 106 | ], 107 | "Resource": "*", 108 | } 109 | ) 110 | 111 | assert not aws_iam_utils.checks.is_read_write_policy(p) 112 | -------------------------------------------------------------------------------- /tests/test_policies_are_equal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import random 3 | import copy 4 | 5 | from policyuniverse.expander_minimizer import minimize_policy 6 | from .context import aws_iam_utils 7 | from aws_iam_utils.util import create_policy 8 | from aws_iam_utils.util import lowercase_policy 9 | from aws_iam_utils.util import statement 10 | 11 | 12 | def reorder_policy(p): 13 | """Returns a copy of p which has had its Statements and nested Actions re-ordered 14 | randomly.""" 15 | result = copy.deepcopy(p) 16 | random.shuffle(result["Statement"]) 17 | 18 | for st in result["Statement"]: 19 | if type(st["Action"]) is list: 20 | random.shuffle(st["Action"]) 21 | 22 | assert result != p # assert the order is actually different 23 | return result 24 | 25 | 26 | def wildcard_policy(p): 27 | """Returns a copy of p which has been minimized (i.e. wildcards inserted).""" 28 | result = copy.deepcopy(p) 29 | result = minimize_policy(result) 30 | 31 | assert result != p # assert the order is actually different 32 | return result 33 | 34 | 35 | @pytest.fixture 36 | def policy_1(): 37 | return create_policy( 38 | statement( 39 | actions=[ 40 | "s3:PutObject", 41 | "s3:PutObjectVersionAcl", 42 | "s3:PutObjectVersionTagging", 43 | "s3:GetObjectVersion", 44 | "s3:GetObjectVersionAcl", 45 | "s3:GetObjectVersionAttributes", 46 | "s3:GetObjectVersionTagging", 47 | "s3:GetObjectVersionForReplication", 48 | "s3:GetObjectVersionTorrent", 49 | "s3:GetObject", 50 | ], 51 | resource="*", 52 | ) 53 | ) 54 | 55 | 56 | @pytest.fixture 57 | def policy_1_reordered(policy_1): 58 | return reorder_policy(policy_1) 59 | 60 | 61 | @pytest.fixture 62 | def policy_1_wildcards(policy_1): 63 | return wildcard_policy(policy_1) 64 | 65 | 66 | @pytest.fixture 67 | def policy_2(): 68 | return create_policy( 69 | { 70 | "Effect": "Allow", 71 | "Action": [ 72 | "s3:PutObject", 73 | "s3:PutObjectVersionAcl", 74 | "s3:PutObjectVersionTagging", 75 | ], 76 | "Resource": "*", 77 | }, 78 | { 79 | "Effect": "Deny", 80 | "Action": [ 81 | "s3:GetObjectLock", 82 | "s3:GetBucketPolicy", 83 | "s3:PutBucketPolicy", 84 | ], 85 | "Resource": "*", 86 | }, 87 | ) 88 | 89 | 90 | @pytest.fixture 91 | def policy_2_reordered(policy_2): 92 | return reorder_policy(policy_2) 93 | 94 | 95 | @pytest.fixture 96 | def policy_2_wildcards(policy_2): 97 | return wildcard_policy(policy_2) 98 | 99 | 100 | def test_policies_equal_when_inputs_equal(policy_1, policy_1_wildcards): 101 | assert aws_iam_utils.checks.policies_are_equal(policy_1, policy_1_wildcards) 102 | 103 | 104 | def test_policies_equal_when_inputs_equal_differing_case(): 105 | p1 = create_policy(statement(actions=["s3:GetObject", "s3:PutObject"])) 106 | p2 = create_policy(statement(actions=["s3:getobject", "s3:putobject"])) 107 | 108 | assert aws_iam_utils.checks.policies_are_equal(p1, p2) 109 | 110 | 111 | def test_policies_equal_when_inputs_equal_but_reordered( 112 | policy_1_reordered, policy_1_wildcards 113 | ): 114 | assert aws_iam_utils.checks.policies_are_equal( 115 | policy_1_reordered, lowercase_policy(policy_1_wildcards) 116 | ) 117 | 118 | 119 | def test_policies_not_equal_when_inputs_differ(policy_1, policy_2): 120 | assert not aws_iam_utils.checks.policies_are_equal(policy_1, policy_2) 121 | 122 | 123 | def test_policies_equal_when_permissions_across_statements(): 124 | p1 = create_policy( 125 | { 126 | "Effect": "Allow", 127 | "Action": [ 128 | "s3:PutObject", 129 | "s3:PutObjectVersionAcl", 130 | "s3:PutObjectVersionTagging", 131 | ], 132 | "Resource": "*", 133 | } 134 | ) 135 | 136 | p2 = create_policy( 137 | { 138 | "Effect": "Allow", 139 | "Action": [ 140 | "s3:PutObject", 141 | ], 142 | "Resource": "*", 143 | }, 144 | { 145 | "Effect": "Allow", 146 | "Action": [ 147 | "s3:PutObjectVersionAcl", 148 | "s3:PutObjectVersionTagging", 149 | ], 150 | "Resource": "*", 151 | }, 152 | ) 153 | 154 | assert aws_iam_utils.checks.policies_are_equal(p1, p2) 155 | 156 | 157 | def test_policies_not_equal_when_permissions_across_statements_differing_resources(): 158 | p1 = create_policy( 159 | { 160 | "Effect": "Allow", 161 | "Action": [ 162 | "s3:PutObject", 163 | "s3:PutObjectVersionAcl", 164 | "s3:PutObjectVersionTagging", 165 | ], 166 | "Resource": "*", 167 | } 168 | ) 169 | 170 | p2 = create_policy( 171 | { 172 | "Effect": "Allow", 173 | "Action": [ 174 | "s3:PutObject", 175 | ], 176 | "Resource": "arn:aws:s3:::my-bucket/*", 177 | }, 178 | { 179 | "Effect": "Allow", 180 | "Action": [ 181 | "s3:PutObjectVersionAcl", 182 | "s3:PutObjectVersionTagging", 183 | ], 184 | "Resource": "*", 185 | }, 186 | ) 187 | 188 | assert not aws_iam_utils.checks.policies_are_equal(p1, lowercase_policy(p2)) 189 | 190 | 191 | def test_policies_equal_with_conditions(): 192 | p1 = create_policy( 193 | { 194 | "Effect": "Allow", 195 | "Action": "s3:PutObject", 196 | "Resource": "*", 197 | "Condition": {"StringNotEqual": {"foo": "bar"}}, 198 | } 199 | ) 200 | 201 | p2 = create_policy( 202 | { 203 | "Effect": "Allow", 204 | "Action": "s3:PutObject", 205 | "Resource": "*", 206 | "Condition": {"StringNotEqual": {"foo": "bar"}}, 207 | } 208 | ) 209 | 210 | assert aws_iam_utils.checks.policies_are_equal(p1, p2) 211 | 212 | 213 | def test_policies_not_equal_with_conditions_differing(): 214 | p1 = create_policy( 215 | { 216 | "Effect": "Allow", 217 | "Action": "s3:PutObject", 218 | "Resource": "*", 219 | "Condition": {"StringNotEqual": {"foo": "bar"}}, 220 | } 221 | ) 222 | 223 | p2 = create_policy( 224 | { 225 | "Effect": "Allow", 226 | "Action": "s3:putobject", 227 | "Resource": "*", 228 | "Condition": {"StringNotEqual": {"foo": "baz"}}, 229 | } 230 | ) 231 | 232 | assert not aws_iam_utils.checks.policies_are_equal(p1, p2) 233 | -------------------------------------------------------------------------------- /tests/test_policy.py: -------------------------------------------------------------------------------- 1 | from aws_iam_utils.policy import PolicyPermissionItem 2 | from aws_iam_utils.policy import policy_from_dict 3 | 4 | from aws_iam_utils.util import extract_policy_permission_items 5 | from aws_iam_utils.util import create_policy 6 | from aws_iam_utils.util import statement 7 | from aws_iam_utils.util import lowercase_policy 8 | from aws_iam_utils.util import create_lowercase_policy 9 | 10 | 11 | def test_create_policy_from_dict(): 12 | policy = create_policy(statement(actions=["s3:PutObject", "s3:GetObject"])) 13 | 14 | p = policy_from_dict(policy) 15 | 16 | assert p.ppis == extract_policy_permission_items(policy) 17 | 18 | 19 | def test_create_policy(): 20 | policy = create_policy(statement(actions=["s3:PutObject", "s3:GetObject"])) 21 | 22 | p = policy_from_dict(policy) 23 | 24 | result = p.as_dict() 25 | 26 | assert result == lowercase_policy(policy) 27 | 28 | 29 | def test_create_policy_after_adding_ppis(): 30 | policy = create_policy(statement(actions=["s3:PutObject", "s3:GetObject"])) 31 | 32 | p = policy_from_dict(policy) 33 | p.ppis.append(PolicyPermissionItem("Allow", "s3:ListBucket")) 34 | p.ppis.append(PolicyPermissionItem("Allow", "s3:GetBucketPolicy")) 35 | p.ppis.append(PolicyPermissionItem("Allow", "s3:PutBucketPolicy")) 36 | 37 | result = p.as_dict() 38 | 39 | assert result == create_lowercase_policy( 40 | statement( 41 | actions=[ 42 | "s3:PutObject", 43 | "s3:GetObject", 44 | "s3:ListBucket", 45 | "s3:GetBucketPolicy", 46 | "s3:PutBucketPolicy", 47 | ] 48 | ) 49 | ) 50 | 51 | 52 | def test_create_policy_after_adding_ppis_complex(): 53 | policy = create_policy( 54 | statement( 55 | actions=["s3:PutObject", "s3:GetObject"], resource="arn:aws:s3:::my-bucket1" 56 | ), 57 | ) 58 | 59 | p = policy_from_dict(policy) 60 | p.ppis.append( 61 | PolicyPermissionItem( 62 | "Allow", "s3:ListBucket", resource="arn:aws:s3:::my-bucket2" 63 | ) 64 | ) 65 | p.ppis.append( 66 | PolicyPermissionItem( 67 | "Allow", "s3:GetBucketPolicy", resource="arn:aws:s3:::my-bucket2" 68 | ) 69 | ) 70 | p.ppis.append( 71 | PolicyPermissionItem( 72 | "Allow", 73 | "s3:GetObject", 74 | resource="arn:aws:s3:::my-bucket3/foo/*", 75 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 76 | ) 77 | ) 78 | 79 | result = p.as_dict() 80 | 81 | assert result == create_lowercase_policy( 82 | statement( 83 | actions=["s3:PutObject", "s3:GetObject"], resource="arn:aws:s3:::my-bucket1" 84 | ), 85 | statement( 86 | actions=["s3:ListBucket", "s3:GetBucketPolicy"], 87 | resource="arn:aws:s3:::my-bucket2", 88 | ), 89 | statement( 90 | actions=["s3:GetObject"], 91 | resource="arn:aws:s3:::my-bucket3/foo/*", 92 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 93 | ), 94 | ) 95 | 96 | 97 | def test_find_action_ppis(): 98 | policy = create_policy( 99 | statement( 100 | actions=["s3:PutObject", "s3:GetObject"], resource="arn:aws:s3:::my-bucket1" 101 | ), 102 | statement( 103 | actions=["s3:ListBucket", "s3:GetBucketPolicy"], 104 | resource="arn:aws:s3:::my-bucket2", 105 | ), 106 | statement( 107 | actions=["s3:GetObject"], 108 | resource="arn:aws:s3:::my-bucket3/foo/*", 109 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 110 | ), 111 | ) 112 | 113 | p = policy_from_dict(policy) 114 | 115 | result = p.find_action_ppis("s3:GetObject") 116 | 117 | assert result == [ 118 | PolicyPermissionItem( 119 | "Allow", "s3:getobject", resource="arn:aws:s3:::my-bucket1" 120 | ), 121 | PolicyPermissionItem( 122 | "Allow", 123 | "s3:getobject", 124 | resource="arn:aws:s3:::my-bucket3/foo/*", 125 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 126 | ), 127 | ] 128 | 129 | 130 | def test_add_policy(): 131 | policy = create_policy( 132 | statement( 133 | actions=["s3:PutObject", "s3:GetObject"], resource="arn:aws:s3:::my-bucket1" 134 | ), 135 | statement( 136 | actions=["s3:ListBucket", "s3:GetBucketPolicy"], 137 | resource="arn:aws:s3:::my-bucket2", 138 | ), 139 | statement( 140 | actions=["s3:GetObject"], 141 | resource="arn:aws:s3:::my-bucket3/foo/*", 142 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 143 | ), 144 | ) 145 | 146 | p = policy_from_dict(policy) 147 | 148 | result = p.find_action_ppis("s3:GetObject") 149 | 150 | assert result == [ 151 | PolicyPermissionItem( 152 | "Allow", "s3:getobject", resource="arn:aws:s3:::my-bucket1" 153 | ), 154 | PolicyPermissionItem( 155 | "Allow", 156 | "s3:getobject", 157 | resource="arn:aws:s3:::my-bucket3/foo/*", 158 | principal={"AWS": "arn:aws:iam:123456789012::role/foo"}, 159 | ), 160 | ] 161 | -------------------------------------------------------------------------------- /tests/test_simplifier.py: -------------------------------------------------------------------------------- 1 | from aws_iam_utils.util import create_policy 2 | from aws_iam_utils.util import statement 3 | from aws_iam_utils.simplifier import simplify_policy 4 | 5 | 6 | def test_simplify_policy(): 7 | p = create_policy( 8 | statement(actions=["s3:*"], resource=["foo"], principal={"AWS": ["bar"]}), 9 | ) 10 | 11 | assert simplify_policy(p) == create_policy( 12 | statement(actions="s3:*", resource="foo", principal={"AWS": "bar"}), 13 | ) 14 | 15 | 16 | def test_simplify_policy_service_principal(): 17 | p = create_policy( 18 | statement(actions=["s3:*"], resource=["foo"], principal={"Service": ["bar"]}), 19 | ) 20 | 21 | assert simplify_policy(p) == create_policy( 22 | statement(actions="s3:*", resource="foo", principal={"Service": "bar"}), 23 | ) 24 | 25 | 26 | def test_simplify_policy_skip_multiple_actions(): 27 | p = create_policy( 28 | statement( 29 | actions=["s3:*", "lambda:*"], 30 | resource=["foo"], 31 | principal={"Service": ["bar"]}, 32 | ), 33 | ) 34 | 35 | assert simplify_policy(p) == create_policy( 36 | statement( 37 | actions=["s3:*", "lambda:*"], resource="foo", principal={"Service": "bar"} 38 | ), 39 | ) 40 | 41 | 42 | def test_simplify_policy_skip_multiple_resources(): 43 | p = create_policy( 44 | statement( 45 | actions=["s3:*"], resource=["foo", "bar"], principal={"Service": ["bar"]} 46 | ), 47 | ) 48 | 49 | assert simplify_policy(p) == create_policy( 50 | statement( 51 | actions="s3:*", resource=["foo", "bar"], principal={"Service": "bar"} 52 | ), 53 | ) 54 | 55 | 56 | def test_simplify_policy_skip_multiple_aws_principals(): 57 | p = create_policy( 58 | statement( 59 | actions=["s3:*"], resource=["foo"], principal={"AWS": ["bar", "baz"]} 60 | ), 61 | ) 62 | 63 | assert simplify_policy(p) == create_policy( 64 | statement(actions="s3:*", resource="foo", principal={"AWS": ["bar", "baz"]}), 65 | ) 66 | 67 | 68 | def test_simplify_policy_skip_multiple_service_principals(): 69 | p = create_policy( 70 | statement( 71 | actions=["s3:*"], resource=["foo"], principal={"Service": ["bar", "baz"]} 72 | ), 73 | ) 74 | 75 | assert simplify_policy(p) == create_policy( 76 | statement( 77 | actions="s3:*", resource="foo", principal={"Service": ["bar", "baz"]} 78 | ), 79 | ) 80 | --------------------------------------------------------------------------------