├── policyglass ├── py.typed ├── utils.py ├── deprecated.py ├── models.py ├── protocols.py ├── __init__.py ├── policy.py ├── action.py ├── resource.py ├── statement.py ├── principal.py └── effective_arp.py ├── tests └── unit │ ├── __init__.py │ ├── bool │ ├── __init__.py │ └── test_effective_condition.py │ ├── union │ ├── __init__.py │ ├── test_effective_condition.py │ ├── test_effective_resource.py │ ├── test_effective_action.py │ ├── test_effective_principal.py │ └── test_policy_shard.py │ ├── contains │ ├── __init__.py │ └── test_effective_action.py │ ├── equality │ ├── __init__.py │ ├── test_effective_action.py │ ├── test_effective_resource.py │ ├── test_effective_principal.py │ ├── test_action.py │ ├── test_resource.py │ ├── test_principal.py │ ├── test_condition.py │ ├── test_effective_condition.py │ └── test_policy_shard.py │ ├── issubset │ ├── __init__.py │ ├── test_effective_principal.py │ ├── test_action.py │ ├── test_resource.py │ ├── test_principal.py │ ├── test_effective_action.py │ └── test_policy_shard.py │ ├── intersection │ ├── __init__.py │ ├── test_effective_action.py │ ├── test_effective_principal.py │ ├── test_effective_condition.py │ └── test_policy_shard.py │ ├── less_or_greater_than │ ├── __init__.py │ ├── test_action.py │ ├── test_resource.py │ ├── test_effective_resource.py │ └── test_principal.py │ ├── pydantic_behaviour │ ├── __init__.py │ ├── test_principal.py │ ├── test_condition.py │ ├── test_policy_shard.py │ ├── test_statement.py │ └── test_policy.py │ ├── test_deprecated.py │ ├── test_principal_collection.py │ ├── test_effective_resource.py │ ├── test_resource.py │ ├── test_principal.py │ ├── test_effective_action.py │ ├── test_statement.py │ ├── test_effective_condition.py │ ├── difference │ ├── test_effective_action.py │ ├── test_effective_principal.py │ └── test_effective_resource.py │ ├── test_policy_shards_explain.py │ ├── test_policy_shards_to_json.py │ ├── test_condition.py │ └── test_policy.py ├── doc_source ├── logo.png ├── class_reference │ ├── action.rst │ ├── resource.rst │ ├── condition.rst │ ├── principal.rst │ ├── statement.rst │ ├── policy_shard.rst │ ├── policy.rst │ ├── understanding_effective_conditions.rst │ ├── understanding_policy_shards.rst │ └── understanding_effective_actions.rst ├── images │ ├── complex_difference.png │ ├── policyglass-sandbox.gif │ ├── action_with_exclusion.png │ ├── action_without_exclusion.png │ └── complex_difference_output.png ├── index.rst ├── class_reference.rst ├── what_is_policyglass.rst ├── conf.py ├── examples_policy_analysis.rst └── examples_policy_shards.rst ├── requirements.txt ├── pyproject.toml ├── setup.cfg ├── .pre-commit-config.yaml ├── Makefile ├── .github └── workflows │ ├── publish.yml │ └── continuous-integration.yml ├── setup.py ├── LICENSE ├── .gitignore ├── CODE_OF_CONDUCT.md ├── README.rst └── CHANGELOG.md /policyglass/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/bool/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/union/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/contains/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/equality/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/issubset/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/intersection/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/less_or_greater_than/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc_source/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/logo.png -------------------------------------------------------------------------------- /doc_source/class_reference/action.rst: -------------------------------------------------------------------------------- 1 | Action 2 | ================ 3 | 4 | .. automodule:: policyglass.action 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/class_reference/resource.rst: -------------------------------------------------------------------------------- 1 | Resource 2 | ================ 3 | 4 | .. automodule:: policyglass.resource 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/class_reference/condition.rst: -------------------------------------------------------------------------------- 1 | Condition 2 | ================ 3 | 4 | .. automodule:: policyglass.condition 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/class_reference/principal.rst: -------------------------------------------------------------------------------- 1 | Principal 2 | ================ 3 | 4 | .. automodule:: policyglass.principal 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/class_reference/statement.rst: -------------------------------------------------------------------------------- 1 | Statement 2 | ================ 3 | 4 | .. automodule:: policyglass.statement 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/images/complex_difference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/images/complex_difference.png -------------------------------------------------------------------------------- /doc_source/images/policyglass-sandbox.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/images/policyglass-sandbox.gif -------------------------------------------------------------------------------- /doc_source/class_reference/policy_shard.rst: -------------------------------------------------------------------------------- 1 | Policy Shard 2 | ================ 3 | 4 | .. automodule:: policyglass.policy_shard 5 | :members: 6 | -------------------------------------------------------------------------------- /doc_source/images/action_with_exclusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/images/action_with_exclusion.png -------------------------------------------------------------------------------- /doc_source/images/action_without_exclusion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/images/action_without_exclusion.png -------------------------------------------------------------------------------- /doc_source/images/complex_difference_output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CloudWanderer-io/PolicyGlass/HEAD/doc_source/images/complex_difference_output.png -------------------------------------------------------------------------------- /doc_source/class_reference/policy.rst: -------------------------------------------------------------------------------- 1 | Policy 2 | ================ 3 | 4 | .. automodule:: policyglass.policy 5 | :members: 6 | :exclude-members: Config 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | flake8 3 | pytest 4 | pydocstyle 5 | sphinx 6 | typing_extensions; python_version < "3.8.0" 7 | pydantic 8 | sphinx_rtd_theme 9 | -e . 10 | -------------------------------------------------------------------------------- /tests/unit/equality/test_effective_action.py: -------------------------------------------------------------------------------- 1 | from policyglass import Action, EffectiveAction 2 | 3 | 4 | def test_equality_true(): 5 | assert EffectiveAction(Action("s3:*")) == EffectiveAction(Action("s3:*")) 6 | -------------------------------------------------------------------------------- /tests/unit/test_deprecated.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import delineate_intersecting_shards 4 | 5 | 6 | def test_delineate_intersecting_shards(): 7 | with pytest.deprecated_call(): 8 | delineate_intersecting_shards(shards=[]) 9 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/test_principal.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from policyglass import Principal 4 | 5 | 6 | def test_json(): 7 | subject = Principal("AWS", "*") 8 | 9 | assert subject.json() == json.dumps({"type": "AWS", "value": "*"}) 10 | -------------------------------------------------------------------------------- /tests/unit/equality/test_effective_resource.py: -------------------------------------------------------------------------------- 1 | from policyglass import EffectiveResource, Resource 2 | 3 | 4 | def test_equality_true(): 5 | assert EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-1*")) == EffectiveResource( 6 | Resource("arn:aws:ec2:*:*:volume/vol-1*") 7 | ) 8 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/test_condition.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from policyglass import Condition 4 | 5 | 6 | def test_json(): 7 | subject = Condition("Key", "Operator", ["Value"]) 8 | 9 | assert subject.json() == json.dumps({"key": "Key", "operator": "Operator", "values": ["Value"]}) 10 | -------------------------------------------------------------------------------- /tests/unit/test_principal_collection.py: -------------------------------------------------------------------------------- 1 | from policyglass import Principal, PrincipalCollection 2 | 3 | 4 | def test_principals(): 5 | assert PrincipalCollection({"AWS": ["arn:aws:iam::123456789012:root"]}).principals == [ 6 | Principal(type="AWS", value="arn:aws:iam::123456789012:root") 7 | ] 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | 4 | [tool.isort] 5 | profile = "black" 6 | multi_line_output = 3 7 | line_length = 120 8 | 9 | [tool.pytest] 10 | testpaths = [ 11 | "tests/integration", 12 | "tests/unit", 13 | "tests/discovery", 14 | ] 15 | log_cli=true 16 | log_level="info" 17 | -------------------------------------------------------------------------------- /tests/unit/equality/test_effective_principal.py: -------------------------------------------------------------------------------- 1 | from policyglass import EffectivePrincipal, Principal 2 | 3 | 4 | def test_equality_true(): 5 | assert EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")) == EffectivePrincipal( 6 | Principal("AWS", "arn:aws:iam::123456789012:root") 7 | ) 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | match_dir = ^(?!tests|doc_source).* 3 | add_ignore = D102, D107 4 | [mypy] 5 | files = policyglass/ 6 | [flake8] 7 | ignore=ANN101,ANN102,ANN001, ANN002,ANN003,RST203,DAR301,DAR201,W503 8 | max-line-length=120 9 | per-file-ignores= 10 | tests/**: ANN001, ANN201, ANN204, ANN206, DAR101 11 | -------------------------------------------------------------------------------- /policyglass/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for PolicyGlass.""" 2 | 3 | 4 | def to_pascal(string: str) -> str: 5 | """Convert a snake_case string into a PascalCase string. 6 | 7 | Parameters: 8 | string: The string to convert to PascalCase. 9 | """ 10 | return "".join(word.capitalize() for word in string.split("_")) 11 | -------------------------------------------------------------------------------- /tests/unit/equality/test_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action 4 | 5 | ACTION_MATCH_SCENARIOS = {"exactly_equal": ["s3:*", "s3:*"], "case_unequal": ["S3:*", "s3:*"]} 6 | 7 | 8 | @pytest.mark.parametrize("_, scenario", ACTION_MATCH_SCENARIOS.items()) 9 | def test_action_equality(_, scenario): 10 | assert Action(scenario[0]) == Action(scenario[1]) 11 | -------------------------------------------------------------------------------- /tests/unit/test_effective_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectiveResource, Resource 4 | 5 | 6 | def test_nonsense_effective_resource(): 7 | with pytest.raises(ValueError): 8 | EffectiveResource( 9 | inclusion=Resource("arn:aws:s3:::examplebucket/*"), 10 | exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}), 11 | ) 12 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_effective_principal.py: -------------------------------------------------------------------------------- 1 | from policyglass import EffectivePrincipal, Principal 2 | 3 | 4 | def test_excluded(): 5 | principal_a = EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")) 6 | principal_b = EffectivePrincipal( 7 | Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")}) 8 | ) 9 | 10 | assert not principal_a.issubset(principal_b) 11 | -------------------------------------------------------------------------------- /tests/unit/test_resource.py: -------------------------------------------------------------------------------- 1 | from policyglass import Resource 2 | 3 | 4 | def test_arn_elements(): 5 | assert Resource("arn:aws:ec2:*:*:volume/*").arn_elements == ["arn", "aws", "ec2", "*", "*", "volume/*"] 6 | 7 | 8 | def test_arn_elements_blanks(): 9 | assert Resource("arn:aws:s3:::bucket_name/key_name").arn_elements == [ 10 | "arn", 11 | "aws", 12 | "s3", 13 | "*", 14 | "*", 15 | "bucket_name/key_name", 16 | ] 17 | -------------------------------------------------------------------------------- /doc_source/index.rst: -------------------------------------------------------------------------------- 1 | .. PolicyGlass documentation master file, created by 2 | sphinx-quickstart on Sun Dec 12 11:11:21 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | 8 | .. toctree:: 9 | :maxdepth: 0 10 | :caption: index 11 | :hidden: 12 | 13 | what_is_policyglass 14 | examples_policy_shards 15 | examples_policy_analysis 16 | class_reference 17 | 18 | .. include:: ../README.rst 19 | -------------------------------------------------------------------------------- /doc_source/class_reference.rst: -------------------------------------------------------------------------------- 1 | Class Reference 2 | =================== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | class_reference/policy 9 | class_reference/policy_shard 10 | class_reference/statement 11 | class_reference/action 12 | class_reference/resource 13 | class_reference/principal 14 | class_reference/condition 15 | class_reference/understanding_effective_conditions 16 | class_reference/understanding_effective_actions 17 | class_reference/understanding_policy_shards 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://gitlab.com/pycqa/flake8 3 | rev: 3.8.4 4 | hooks: 5 | - id: flake8 6 | additional_dependencies: 7 | - flake8-annotations 8 | - darglint 9 | - repo: https://github.com/pycqa/pydocstyle 10 | rev: 5.1.1 11 | hooks: 12 | - id: pydocstyle 13 | exclude: ^(tests|doc_source).* 14 | - repo: https://github.com/timothycrosley/isort 15 | rev: 5.7.0 16 | hooks: 17 | - id: isort 18 | - repo: https://github.com/pre-commit/mirrors-mypy 19 | rev: v0.812 20 | hooks: 21 | - id: mypy 22 | exclude: ^doc_source/|^tests/ 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = doc_source 9 | BUILDDIR = doc_build 10 | HTML_DIR = docs 11 | 12 | # Put it first so that "make" without argument is like "make help". 13 | help: 14 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 15 | 16 | .PHONY: help Makefile 17 | 18 | # Catch-all target: route all unknown targets to Sphinx using the new 19 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 20 | %: Makefile 21 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" -P $(SPHINXOPTS) $(O) 22 | -------------------------------------------------------------------------------- /tests/unit/less_or_greater_than/test_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action 4 | 5 | ACTION_LT_SCENARIOS = { 6 | "smaller": ["s3:get*", "s3:*"], 7 | "smaller_mismatching_case": ["s3:get*", "S3:*"], 8 | } 9 | 10 | 11 | @pytest.mark.parametrize("_, scenario", ACTION_LT_SCENARIOS.items()) 12 | def test_action_less_than(_, scenario): 13 | assert Action(scenario[0]) < Action(scenario[1]) 14 | 15 | 16 | ACTION_GT_SCENARIOS = { 17 | "exactly_equal": ["s3:*", "s3:*"], 18 | "case_unequal": ["S3:*", "s3:*"], 19 | "larger": ["s3:*", "s3:get*"], 20 | } 21 | 22 | 23 | @pytest.mark.parametrize("_, scenario", ACTION_GT_SCENARIOS.items()) 24 | def test_action_greater_than(_, scenario): 25 | assert not Action(scenario[0]) < Action(scenario[1]) 26 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action 4 | 5 | ACTION_ISSUBSET_SCENARIOS = { 6 | "smaller": ["s3:get*", "s3:*"], 7 | "smaller_mismatching_case": ["s3:get*", "S3:*"], 8 | "exactly_equal": ["s3:*", "s3:*"], 9 | "case_unequal": ["S3:*", "s3:*"], 10 | } 11 | 12 | 13 | @pytest.mark.parametrize("_, scenario", ACTION_ISSUBSET_SCENARIOS.items()) 14 | def test_action_issubset(_, scenario): 15 | assert Action(scenario[0]).issubset(Action(scenario[1])) 16 | 17 | 18 | ACTION_NOT_ISSUBSET_SCENARIOS = { 19 | "larger": ["s3:*", "s3:get*"], 20 | } 21 | 22 | 23 | @pytest.mark.parametrize("_, scenario", ACTION_NOT_ISSUBSET_SCENARIOS.items()) 24 | def test_action_not_issubset(_, scenario): 25 | assert not Action(scenario[0]).issubset(Action(scenario[1])) 26 | -------------------------------------------------------------------------------- /policyglass/deprecated.py: -------------------------------------------------------------------------------- 1 | """Holds any aliases for deprecated functions or classes.""" 2 | import warnings 3 | from typing import Iterable, List 4 | 5 | from policyglass.policy_shard import PolicyShard, dedupe_policy_shards 6 | 7 | 8 | def delineate_intersecting_shards(shards: Iterable[PolicyShard], check_reverse: bool = True) -> List["PolicyShard"]: 9 | """Alias dedupe_policy_shards. 10 | 11 | Parameters: 12 | shards: The shards to deduplicate. 13 | check_reverse: Whether you want to check these shards in reverse as well (only disabled when calling itself). 14 | """ 15 | warnings.warn( 16 | "delineate_intersecting_shards is deprecated and will be removed in v1. " 17 | "Please use dedupe_policy_shards instead", 18 | DeprecationWarning, 19 | ) 20 | return dedupe_policy_shards(shards=shards) 21 | -------------------------------------------------------------------------------- /tests/unit/equality/test_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Resource 4 | 5 | RESOURCE_SCENARIOS = { 6 | "exactly_equal": [ 7 | "arn:aws:iam::123456789012:role/role-name", 8 | "arn:aws:iam::123456789012:role/role-name", 9 | ], 10 | } 11 | 12 | 13 | @pytest.mark.parametrize("_, scenario", RESOURCE_SCENARIOS.items()) 14 | def test_resource_equality(_, scenario): 15 | assert Resource(scenario[0]) == Resource(scenario[1]) 16 | 17 | 18 | RESOURCE_NOT_MATCH_SCENARIOS = { 19 | "case_unequal": [ 20 | "arn:aws:iam::123456789012:role/role-name", 21 | "arn:aws:iam::123456789012:role/Role-Name", 22 | ], 23 | } 24 | 25 | 26 | @pytest.mark.parametrize("_, scenario", RESOURCE_NOT_MATCH_SCENARIOS.items()) 27 | def test_resource_inequality(_, scenario): 28 | assert Resource(scenario[0]) != Resource(scenario[1]) 29 | -------------------------------------------------------------------------------- /tests/unit/test_principal.py: -------------------------------------------------------------------------------- 1 | from policyglass import Principal 2 | 3 | 4 | def test_short_account_id(): 5 | assert str(Principal("AWS", "123456789012")) == "AWS arn:aws:iam::123456789012:root" 6 | 7 | 8 | def test_account_id(): 9 | assert Principal(type="AWS", value="arn:aws:iam::123456789012:root").account_id == "123456789012" 10 | 11 | 12 | def test_is_account(): 13 | assert Principal(type="AWS", value="arn:aws:iam::123456789012:root").is_account 14 | 15 | 16 | def test_is_account_false(): 17 | assert not Principal("AWS", "arn:aws:iam::123456789012:role/role-name").is_account 18 | 19 | 20 | def test_arn_elements(): 21 | assert Principal("AWS", "arn:aws:iam::123456789012:role/role-name").arn_elements == [ 22 | "arn", 23 | "aws", 24 | "iam", 25 | "", 26 | "123456789012", 27 | "role/role-name", 28 | ] 29 | -------------------------------------------------------------------------------- /tests/unit/equality/test_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Principal 4 | 5 | PRINCIPAL_MATCH_SCENARIOS = { 6 | "exactly_equal": [ 7 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 8 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 9 | ], 10 | } 11 | 12 | 13 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_MATCH_SCENARIOS.items()) 14 | def test_action_equality(_, scenario): 15 | assert scenario[0] == scenario[1] 16 | 17 | 18 | PRINCIPAL_NOT_MATCH_SCENARIOS = { 19 | "case_unequal": [ 20 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 21 | Principal("AWS", "arn:aws:iam::123456789012:role/rolename"), 22 | ], 23 | } 24 | 25 | 26 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_NOT_MATCH_SCENARIOS.items()) 27 | def test_action_unequality(_, scenario): 28 | assert scenario[0] != scenario[1] 29 | -------------------------------------------------------------------------------- /tests/unit/less_or_greater_than/test_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Resource 4 | 5 | RESOURCE_LT_SCENARIOS = { 6 | "smaller": ["arn:aws:ec2:*:*:volume/vol-12345678", "arn:aws:ec2:*:*:volume/*"], 7 | } 8 | 9 | 10 | @pytest.mark.parametrize("_, scenario", RESOURCE_LT_SCENARIOS.items()) 11 | def test_resource_contains(_, scenario): 12 | assert Resource(scenario[0]) < Resource(scenario[1]) 13 | 14 | 15 | RESOURCE_NOT_LT_SCENARIOS = { 16 | "exactly_equal": ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*:*:volume/*"], 17 | "case_unequal": ["arn:aws:ec2:*:*:Volume/*", "arn:aws:ec2:*:*:volume/*"], 18 | "larger": ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*:*:volume/vol-12345678"], 19 | } 20 | 21 | 22 | @pytest.mark.parametrize("_, scenario", RESOURCE_NOT_LT_SCENARIOS.items()) 23 | def test_resource_not_less_than(_, scenario): 24 | assert not Resource(scenario[0]) < Resource(scenario[1]) 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and PyPI 2 | on: [push] 3 | jobs: 4 | build-n-publish: 5 | name: Build and publish Python 🐍 distributions 📦 to PyPI and PyPI 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@master 9 | - name: Set up Python 3.7 10 | uses: actions/setup-python@v1 11 | with: 12 | python-version: 3.7 13 | - name: Install pypa/build 14 | run: >- 15 | python -m 16 | pip install 17 | build 18 | --user 19 | - name: Build a binary wheel and a source tarball 20 | run: >- 21 | python -m 22 | build 23 | --sdist 24 | --wheel 25 | --outdir dist/ 26 | - name: Publish distribution 📦 to PyPI 27 | if: startsWith(github.ref, 'refs/tags') 28 | uses: pypa/gh-action-pypi-publish@master 29 | with: 30 | password: ${{ secrets.PYPI_API_TOKEN }} 31 | -------------------------------------------------------------------------------- /policyglass/models.py: -------------------------------------------------------------------------------- 1 | """Generic Models.""" 2 | 3 | 4 | class CaseInsensitiveString(str): 5 | """A case insensitive string to aid comparison.""" 6 | 7 | def __eq__(self, other: object) -> bool: 8 | """Determine whether this object and another object are equal. 9 | 10 | Parameters: 11 | other: The object to compare this one to. 12 | 13 | Raises: 14 | ValueError: When the object we are compared with is not of the same type. 15 | """ 16 | if not isinstance(other, (self.__class__, str)): 17 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 18 | return self.lower() == other.lower() 19 | 20 | def __hash__(self) -> int: 21 | """Compute the hash for this object.""" 22 | return hash(self.lower()) 23 | 24 | def __repr__(self) -> str: 25 | """Return an instantiable representation of this object.""" 26 | return f"{self.__class__.__name__}('{self}')" 27 | -------------------------------------------------------------------------------- /tests/unit/bool/test_effective_condition.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Condition, EffectiveCondition 4 | 5 | TRUTHY_EFFECTIVE_CONDITION_SCENARIOS = { 6 | "inclusions_populated": EffectiveCondition( 7 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 8 | frozenset(), 9 | ), 10 | "exclusions_populated": EffectiveCondition( 11 | frozenset(), 12 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 13 | ), 14 | } 15 | 16 | 17 | @pytest.mark.parametrize("_, scenario", TRUTHY_EFFECTIVE_CONDITION_SCENARIOS.items()) 18 | def test_effective_condition_truthy(_, scenario): 19 | assert scenario 20 | 21 | 22 | FALSEY_EFFECTIVE_CONDITION_SCENARIOS = { 23 | "nothing_populated": EffectiveCondition( 24 | frozenset(), 25 | frozenset(), 26 | ), 27 | } 28 | 29 | 30 | @pytest.mark.parametrize("_, scenario", FALSEY_EFFECTIVE_CONDITION_SCENARIOS.items()) 31 | def test_effective_condition_falsey(_, scenario): 32 | assert not scenario 33 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Resource 4 | 5 | RESOURCE_IN_SCENARIOS = { 6 | "smaller": ["arn:aws:ec2:*:*:volume/vol-1*", "arn:aws:ec2:*:*:volume/*"], 7 | "exactly_equal": ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*:*:volume/*"], 8 | } 9 | 10 | 11 | @pytest.mark.parametrize("_, scenario", RESOURCE_IN_SCENARIOS.items()) 12 | def test_action_issubset(_, scenario): 13 | assert Resource(scenario[0]).issubset(Resource(scenario[1])) 14 | 15 | 16 | RESOURCE_NOT_IN_SCENARIOS = { 17 | "larger": ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*:*:volume/vol-1*"], 18 | "case_unequal": ["arn:aws:ec2:*:*:Volume/*", "arn:aws:ec2:*:*:volume/*"], 19 | "exactly_equal_mismatching_case": ["arn:aws:ec2:*:*:Volume/*", "arn:aws:ec2:*:*:volume/*"], 20 | "smaller_mismatching_case": ["arn:aws:ec2:*:*:volume/vol-1*", "arn:aws:ec2:*:*:Volume/*"], 21 | } 22 | 23 | 24 | @pytest.mark.parametrize("_, scenario", RESOURCE_NOT_IN_SCENARIOS.items()) 25 | def test_action_not_issubset(_, scenario): 26 | assert not Resource(scenario[0]).issubset(Resource(scenario[1])) 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Setup CloudWanderer package.""" 3 | import re 4 | from os import path 5 | 6 | from setuptools import find_packages, setup 7 | 8 | this_directory = path.abspath(path.dirname(__file__)) 9 | with open(path.join(this_directory, "README.rst"), encoding="utf-8") as f: 10 | long_description = re.sub(r"..\s*doctest\s*::", ".. code-block ::", f.read()) 11 | 12 | long_description = re.sub(r":class:`~[^`]+\.([^`]+)`", "\1", long_description) 13 | 14 | setup( 15 | version="0.8.0", 16 | python_requires=">=3.6.0", 17 | name="policyglass", 18 | packages=find_packages(include=["policyglass", "policyglass.*"]), 19 | description="Understand the effective permissions of your policies", 20 | long_description=long_description, 21 | long_description_content_type="text/x-rst", 22 | author="Sam Martin", 23 | author_email="samjackmartin+policyglass@gmail.com", 24 | url="https://github.com/CloudWanderer-io/PolicyGlass", 25 | install_requires=["pydantic", 'typing_extensions; python_version < "3.8.0"'], 26 | package_data={ 27 | "": ["py.typed"], 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Sam Martin 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 | -------------------------------------------------------------------------------- /policyglass/protocols.py: -------------------------------------------------------------------------------- 1 | """Protocol types used by PolicyGlass.""" 2 | import sys 3 | 4 | if sys.version_info >= (3, 8): 5 | from typing import Protocol 6 | else: 7 | from typing_extensions import Protocol 8 | 9 | 10 | class ARPProtocol(Protocol): 11 | """Protocol which Actions, Resources, and Principals must implement.""" 12 | 13 | def __eq__(self, other: object) -> bool: 14 | """Determine whether this object and another object are equal. 15 | 16 | Parameters: 17 | other: The object to compare this one to. 18 | """ 19 | ... 20 | 21 | def issubset(self, other: object) -> bool: 22 | """Whether this object contains all the elements of another object (i.e. is a subset of the other object). 23 | 24 | Parameters: 25 | other: The object to determine if our object contains. 26 | """ 27 | ... 28 | 29 | def __lt__(self, other: object) -> bool: 30 | """Whether this object contains but is not equal to (i.e. a proper subset) another object. 31 | 32 | Parameters: 33 | other: The object to determine if our object contains (but is not equal to). 34 | """ 35 | ... 36 | -------------------------------------------------------------------------------- /tests/unit/less_or_greater_than/test_effective_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectiveResource, Resource 4 | 5 | RESOURCE_LT_SCENARIOS = { 6 | "smaller": [ 7 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-12345678")), 8 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 9 | ], 10 | } 11 | 12 | 13 | @pytest.mark.parametrize("_, scenario", RESOURCE_LT_SCENARIOS.items()) 14 | def test_resource_contains(_, scenario): 15 | assert scenario[0] < scenario[1] 16 | 17 | 18 | RESOURCE_NOT_LT_SCENARIOS = { 19 | "exactly_equal": [ 20 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 21 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 22 | ], 23 | "case_unequal": [ 24 | EffectiveResource(Resource("arn:aws:ec2:*:*:Volume/*")), 25 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 26 | ], 27 | "larger": [ 28 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 29 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-12345678")), 30 | ], 31 | } 32 | 33 | 34 | @pytest.mark.parametrize("_, scenario", RESOURCE_NOT_LT_SCENARIOS.items()) 35 | def test_resource_not_less_than(_, scenario): 36 | assert not scenario[0] < scenario[1] 37 | -------------------------------------------------------------------------------- /tests/unit/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction 4 | 5 | 6 | def test_in_exclusions_bad_comparison(): 7 | assert not EffectiveAction(Action("S3:*"), frozenset({Action("s3:get*")})).in_exclusions(Action("s3:putobject")) 8 | 9 | 10 | IN_EXCLUSIONS_TRUE_SCENARIOS = { 11 | "smaller": [EffectiveAction(Action("S3:*"), frozenset({Action("s3:get*")})), Action("s3:getobject")], 12 | "equal": [EffectiveAction(Action("S3:*"), frozenset({Action("s3:get*")})), Action("s3:get*")], 13 | } 14 | 15 | 16 | @pytest.mark.parametrize("_, scenario", IN_EXCLUSIONS_TRUE_SCENARIOS.items()) 17 | def test_in_exclusions_true(_, scenario): 18 | effective_action, action = scenario 19 | assert effective_action.in_exclusions(action) 20 | 21 | 22 | def test_in_exclusions_false(): 23 | assert not EffectiveAction(Action("S3:*"), frozenset({Action("s3:get*")})).in_exclusions(Action("s3:putobject")) 24 | 25 | 26 | def test_raise_if_nonsense_arp(): 27 | with pytest.raises(ValueError) as ex: 28 | EffectiveAction(Action("S3:*"), frozenset({Action("*")})) 29 | assert "Exclusions ([Action('*')]) are not within the inclusion (Action('S3:*'))" in str(ex.value) 30 | 31 | 32 | def test_nothing_if_nonsense_arp_factory(): 33 | 34 | assert EffectiveAction.factory(Action("S3:*"), frozenset({Action("*")})) is None 35 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/test_policy_shard.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from policyglass import ( 4 | Action, 5 | Condition, 6 | EffectiveAction, 7 | EffectivePrincipal, 8 | EffectiveResource, 9 | PolicyShard, 10 | Principal, 11 | Resource, 12 | ) 13 | from policyglass.condition import EffectiveCondition 14 | 15 | 16 | def test_json(): 17 | subject = PolicyShard( 18 | effect="Allow", 19 | effective_action=EffectiveAction(inclusion=Action("s3:*")), 20 | effective_resource=EffectiveResource(inclusion=Resource("*")), 21 | effective_principal=EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 22 | effective_condition=EffectiveCondition(frozenset([Condition("Key", "Operator", ["Value"])])), 23 | ) 24 | 25 | assert subject.json() == json.dumps( 26 | { 27 | "effective_action": {"inclusion": "s3:*", "exclusions": []}, 28 | "effective_resource": {"inclusion": "*", "exclusions": []}, 29 | "effective_principal": { 30 | "inclusion": {"type": "AWS", "value": "arn:aws:iam::123456789012:root"}, 31 | "exclusions": [], 32 | }, 33 | "effective_condition": { 34 | "inclusions": [{"key": "Key", "operator": "Operator", "values": ["Value"]}], 35 | "exclusions": [], 36 | }, 37 | } 38 | ) 39 | -------------------------------------------------------------------------------- /policyglass/__init__.py: -------------------------------------------------------------------------------- 1 | """PolicyGlass.""" 2 | from .action import Action, EffectiveAction 3 | from .condition import ( 4 | Condition, 5 | ConditionKey, 6 | ConditionOperator, 7 | ConditionValue, 8 | EffectiveCondition, 9 | RawConditionCollection, 10 | ) 11 | from .deprecated import delineate_intersecting_shards 12 | from .policy import Policy 13 | from .policy_shard import ( 14 | PolicyShard, 15 | dedupe_policy_shards, 16 | explain_policy_shards, 17 | policy_shards_effect, 18 | policy_shards_to_json, 19 | ) 20 | from .principal import EffectivePrincipal, Principal, PrincipalCollection, PrincipalType, PrincipalValue 21 | from .resource import EffectiveResource, Resource 22 | from .statement import Statement 23 | 24 | __all__ = [ 25 | "Policy", 26 | "Statement", 27 | "Principal", 28 | "Action", 29 | "Resource", 30 | "PrincipalCollection", 31 | "Principal", 32 | "PrincipalType", 33 | "PrincipalValue", 34 | "Condition", 35 | "ConditionKey", 36 | "ConditionOperator", 37 | "ConditionValue", 38 | "RawConditionCollection", 39 | "EffectiveAction", 40 | "EffectiveResource", 41 | "EffectivePrincipal", 42 | "EffectiveCondition", 43 | "PolicyShard", 44 | "policy_shards_effect", 45 | "dedupe_policy_shards", 46 | "policy_shards_to_json", 47 | "explain_policy_shards", 48 | "delineate_intersecting_shards", 49 | ] 50 | -------------------------------------------------------------------------------- /tests/unit/contains/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction, Resource 4 | 5 | 6 | def test_bad_contains(): 7 | with pytest.raises(ValueError) as ex: 8 | Resource("*") in EffectiveAction(Action("S3:PutObject")) 9 | 10 | assert "Cannot check if EffectiveAction contains a Resource" in str(ex) 11 | 12 | 13 | EFFECTIVE_ACTION_CONTAINS_SCENARIOS = { 14 | "exactly_equal": {"container": EffectiveAction(Action("S3:PutObject")), "contains": Action("S3:PutObject")}, 15 | "subset": {"container": EffectiveAction(Action("S3:Put*")), "contains": Action("S3:PutObject")}, 16 | "not_excluded": { 17 | "container": EffectiveAction(Action("S3:Put*"), frozenset({Action("S3:PutACL")})), 18 | "contains": Action("S3:PutObject"), 19 | }, 20 | } 21 | 22 | 23 | @pytest.mark.parametrize("_, scenario", EFFECTIVE_ACTION_CONTAINS_SCENARIOS.items()) 24 | def test_action_contains(_, scenario): 25 | container = scenario["container"] 26 | contains = scenario["contains"] 27 | 28 | assert contains in container 29 | 30 | 31 | EFFECTIVE_ACTION_NOT_CONTAINS_SCENARIOS = { 32 | "superset": {"container": EffectiveAction(Action("S3:PutObject")), "contains": Action("S3:*")}, 33 | "excluded": { 34 | "container": EffectiveAction(Action("S3:Put*"), frozenset({Action("S3:PutObject")})), 35 | "contains": Action("S3:PutObject"), 36 | }, 37 | } 38 | 39 | 40 | @pytest.mark.parametrize("_, scenario", EFFECTIVE_ACTION_NOT_CONTAINS_SCENARIOS.items()) 41 | def test_action_not_contains(_, scenario): 42 | container = scenario["container"] 43 | contains = scenario["contains"] 44 | 45 | assert contains not in container 46 | -------------------------------------------------------------------------------- /tests/unit/less_or_greater_than/test_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Principal 4 | 5 | PRINCIPAL_LT_SCENARIOS = { 6 | "wildcar": [ 7 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 8 | Principal("AWS", "*"), 9 | ], 10 | "full_account": [ 11 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 12 | Principal("AWS", "arn:aws:iam::123456789012:root"), 13 | ], 14 | "short_account": [ 15 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 16 | Principal("AWS", "123456789012"), 17 | ], 18 | } 19 | 20 | 21 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_LT_SCENARIOS.items()) 22 | def test_principal_lt(_, scenario): 23 | assert scenario[0] < scenario[1] 24 | 25 | 26 | PRINCIPAL_NOT_LT_SCENARIOS = { 27 | "exactly_equal": [ 28 | Principal("AWS", "arn:aws:iam::123456789012:role/role-name"), 29 | Principal("AWS", "arn:aws:iam::123456789012:role/role-name"), 30 | ], 31 | "case_unequal": [ 32 | Principal("AWS", "arn:aws:iam::123456789012:role/Role-Name"), 33 | Principal("AWS", "arn:aws:iam::123456789012:role/role-name"), 34 | ], 35 | "larger": [ 36 | Principal("AWS", "arn:aws:iam::123456789012:root"), 37 | Principal("AWS", "arn:aws:iam::123456789012:role/role-name"), 38 | ], 39 | "type_incorrect": [ 40 | Principal("AWS", "arn:aws:iam::123456789012:role/role-name"), 41 | Principal("Federated", "*"), 42 | ], 43 | } 44 | 45 | 46 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_NOT_LT_SCENARIOS.items()) 47 | def test_principal_not_contains(_, scenario): 48 | assert not scenario[0] < scenario[1] 49 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Principal 4 | 5 | PRINCIPAL_ISSUBSET_SCENARIOS = { 6 | "wildcard": [ 7 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 8 | Principal("AWS", "*"), 9 | ], 10 | "full_account": [ 11 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 12 | Principal("AWS", "arn:aws:iam::123456789012:root"), 13 | ], 14 | "short_account": [ 15 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 16 | Principal("AWS", "123456789012"), 17 | ], 18 | "exactly_equal": [ 19 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 20 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 21 | ], 22 | } 23 | 24 | 25 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_ISSUBSET_SCENARIOS.items()) 26 | def test_principal_lt(_, scenario): 27 | assert scenario[0].issubset(scenario[1]) 28 | 29 | 30 | PRINCIPAL_NOT_ISSUBSET_SCENARIOS = { 31 | "case_unequal": [ 32 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 33 | Principal("AWS", "arn:aws:iam::123456789012:role/rolename"), 34 | ], 35 | "larger": [ 36 | Principal("AWS", "arn:aws:iam::123456789012:root"), 37 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 38 | ], 39 | "type_incorrect": [ 40 | Principal("AWS", "arn:aws:iam::123456789012:role/RoleName"), 41 | Principal("Federated", "*"), 42 | ], 43 | } 44 | 45 | 46 | @pytest.mark.parametrize("_, scenario", PRINCIPAL_NOT_ISSUBSET_SCENARIOS.items()) 47 | def test_principal_not_contains(_, scenario): 48 | assert not scenario[0].issubset(scenario[1]) 49 | -------------------------------------------------------------------------------- /tests/unit/equality/test_condition.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Condition, RawConditionCollection 4 | 5 | CONDITION_MATCH_SCENARIOS = { 6 | "exactly_equal": [ 7 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 8 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 9 | ], 10 | "mismatched_operator_case": [ 11 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 12 | {"ArnEquals": {"ec2:SourceInstanceArn": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 13 | ], 14 | "mismatched_key_case": [ 15 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 16 | {"arnequals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 17 | ], 18 | } 19 | 20 | 21 | @pytest.mark.parametrize("_, scenario", CONDITION_MATCH_SCENARIOS.items()) 22 | def test_condition_equality(_, scenario): 23 | assert RawConditionCollection(**scenario[0]) == RawConditionCollection(**scenario[1]) 24 | 25 | 26 | CONDITION_NOT_MATCH_SCENARIOS = { 27 | "mismatched_value_case": [ 28 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/Instance-Id"]}}, 29 | {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 30 | ], 31 | } 32 | 33 | 34 | @pytest.mark.parametrize("_, scenario", CONDITION_NOT_MATCH_SCENARIOS.items()) 35 | def test_condition_inequality(_, scenario): 36 | assert RawConditionCollection(**scenario[0]) != RawConditionCollection(**scenario[1]) 37 | 38 | 39 | @pytest.mark.parametrize("_, scenario", CONDITION_MATCH_SCENARIOS.items()) 40 | def test_condition_shard_equality(_, scenario): 41 | assert Condition.factory(scenario[0]) == Condition.factory(scenario[1]) 42 | -------------------------------------------------------------------------------- /policyglass/policy.py: -------------------------------------------------------------------------------- 1 | """Core Policy class.""" 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from .policy_shard import PolicyShard 7 | from .statement import Statement 8 | from .utils import to_pascal 9 | 10 | 11 | class Policy(BaseModel): 12 | """Main policy class. 13 | 14 | Example: 15 | Create a policy from a dictionary. 16 | 17 | >>> from policyglass import Policy 18 | >>> Policy(**{ 19 | ... "Version": "2012-10-17", 20 | ... "Statement": [ 21 | ... { 22 | ... "Effect": "Allow", 23 | ... "Action": [ 24 | ... "s3:*" 25 | ... ], 26 | ... "Resource": "*" 27 | ... } 28 | ... ] 29 | ... }) 30 | Policy(version='2012-10-17', 31 | statement=[Statement(effect='Allow', 32 | action=[Action('s3:*')], 33 | not_action=None, 34 | resource=[Resource('*')], 35 | not_resource=None, principal=None, 36 | not_principal=None, 37 | condition=None)]) 38 | """ 39 | 40 | version: Optional[str] 41 | statement: List[Statement] 42 | 43 | class Config: 44 | """Configure the pydantic BaseModel.""" 45 | 46 | alias_generator = to_pascal 47 | 48 | @property 49 | def policy_shards(self) -> List[PolicyShard]: 50 | """Shatter this policy into a number :class:`policyglass.policy_shard` objects.""" 51 | result = [] 52 | for statement in self.statement: 53 | result.extend(statement.policy_shards) 54 | return result 55 | 56 | def policy_json(self) -> str: 57 | """Return a valid policy JSON from this policy.""" 58 | return self.json(by_alias=True, exclude_none=True) 59 | -------------------------------------------------------------------------------- /tests/unit/union/test_effective_condition.py: -------------------------------------------------------------------------------- 1 | from policyglass import Condition, EffectiveCondition 2 | 3 | 4 | def test_different_inclusions(): 5 | effective_condition_a = EffectiveCondition(frozenset({Condition("Key", "Operator", ["value"])})) 6 | effective_condition_b = EffectiveCondition(frozenset({Condition("AnotherKey", "Operator", ["value"])})) 7 | 8 | assert effective_condition_a.union(effective_condition_b) == EffectiveCondition( 9 | frozenset({Condition("Key", "Operator", ["value"]), Condition("AnotherKey", "Operator", ["value"])}), 10 | ) 11 | 12 | 13 | def test_same_inclusions(): 14 | effective_condition_a = EffectiveCondition(frozenset({Condition("Key", "Operator", ["value"])})) 15 | effective_condition_b = EffectiveCondition(frozenset({Condition("Key", "Operator", ["value"])})) 16 | 17 | assert effective_condition_a.union(effective_condition_b) == EffectiveCondition( 18 | frozenset({Condition("Key", "Operator", ["value"])}) 19 | ) 20 | 21 | 22 | def test_different_exclusions(): 23 | effective_condition_a = EffectiveCondition(frozenset(), frozenset({Condition("Key", "Operator", ["value"])})) 24 | effective_condition_b = EffectiveCondition(frozenset(), frozenset({Condition("AnotherKey", "Operator", ["value"])})) 25 | 26 | assert effective_condition_a.union(effective_condition_b) == EffectiveCondition( 27 | frozenset(), 28 | frozenset({Condition("Key", "Operator", ["value"]), Condition("AnotherKey", "Operator", ["value"])}), 29 | ) 30 | 31 | 32 | def test_same_exclusions(): 33 | effective_condition_a = EffectiveCondition(frozenset(), frozenset({Condition("Key", "Operator", ["value"])})) 34 | effective_condition_b = EffectiveCondition(frozenset(), frozenset({Condition("Key", "Operator", ["value"])})) 35 | 36 | assert effective_condition_a.union(effective_condition_b) == EffectiveCondition( 37 | frozenset(), frozenset({Condition("Key", "Operator", ["value"])}) 38 | ) 39 | -------------------------------------------------------------------------------- /tests/unit/union/test_effective_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectiveResource, Resource 4 | 5 | 6 | def test_bad_union(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-1*")).union(Resource("arn:aws:ec2:*:*:volume/vol-1*")) 9 | 10 | assert "Cannot union EffectiveResource with Resource" in str(ex.value) 11 | 12 | 13 | def test_union_simple(): 14 | assert EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-1*")).union( 15 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-123123123")) 16 | ) == [EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-1*"))] 17 | 18 | 19 | def test_union_excluded_resource_addition(): 20 | """If we have an inclusion that is a subset of another EffectiveResource's exclusions it must not be eliminated. 21 | This is because it represents an additional allow which wasn't subject to the same exclusion in its original 22 | statement. If it had been then it would have self-destructed by its own exclusions. 23 | """ 24 | a = EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-1*")})) 25 | b = EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-123123123")) 26 | 27 | assert a.union(b) == [ 28 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-1*")})), 29 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-123123123")), 30 | ] 31 | 32 | 33 | def test_union_disjoint(): 34 | a = EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-1*")})) 35 | b = EffectiveResource(Resource("arn:aws:s3:::examplebucket/*")) 36 | 37 | assert a.union(b) == [ 38 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-1*")})), 39 | EffectiveResource(Resource("arn:aws:s3:::examplebucket/*")), 40 | ] 41 | -------------------------------------------------------------------------------- /doc_source/what_is_policyglass.rst: -------------------------------------------------------------------------------- 1 | What is PolicyGlass? 2 | ====================== 3 | 4 | PolicyGlass is an effective permission parser for AWS Policies. It takes normal JSON policies of any type 5 | (Principal, Resource, or Endpoint) and converts them into :class:`~policyglass.policy_shard.PolicyShard` objects 6 | that are *always* assertions about what is allowed. 7 | 8 | Use Cases 9 | ---------- 10 | 11 | There are two main use cases for this tool: 12 | 13 | #. Writing tools that audit the permissions provided to AWS resources/principals 14 | #. Validating your understanding of the complex policy you're writing. 15 | 16 | 17 | Why do I need PolicyGlass? 18 | -------------------------------------- 19 | 20 | Isn't this a simple problem? I can just check actions and resources in each statement, boom, done. 21 | 22 | Understanding AWS policies programmatically is harder than it looks. 23 | 24 | You can write code easily enough to check what resources and actions are in each statement, 25 | and that might seem like enough. But what happens when you throw a ``Deny`` statement into the mix? 26 | Well that's okay, you just check each statement to see if it's an allow or a deny and if it's a deny 27 | then you just remove any resources from the allow that exist in the deny right? 28 | Easy enough, but what about resources that are just ``*`` or are ARNs with wildcards in them? 29 | Once you've got past that, you have to deal with statements that contain negations 30 | (``NotAction``, ``NotResource``, and ``NotPrincipal``), it's starting to get harder. 31 | Then you have to add in the complexity of conditions, and all this is without even mentioning the complexity 32 | of parsing an AWS Policy in the first place with the variants of ``Actions`` as a list or as a string, or 33 | ``Resources`` that may be a string or a dictionary. 34 | 35 | PolicyGlass takes care of all this for you by breaking down a policy into its components and applying set 36 | operations in order to build shards that describe the effective permissions. 37 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/test_statement.py: -------------------------------------------------------------------------------- 1 | from policyglass import Statement 2 | import pytest 3 | import json 4 | 5 | ENSURE_LIST_SCENARIOS = { 6 | "action": {"input": {"Effect": "Allow", "Action": "s3:*"}, "expected": {"Effect": "Allow", "Action": ["s3:*"]}}, 7 | "not_action": { 8 | "input": {"Effect": "Allow", "NotAction": "s3:*"}, 9 | "expected": {"Effect": "Allow", "NotAction": ["s3:*"]}, 10 | }, 11 | "resource": { 12 | "input": {"Effect": "Allow", "Resource": "arn:aws:ec2:*:*:volume/*"}, 13 | "expected": {"Effect": "Allow", "Resource": ["arn:aws:ec2:*:*:volume/*"]}, 14 | }, 15 | "not_resource": { 16 | "input": {"Effect": "Allow", "NotResource": "arn:aws:ec2:*:*:volume/*"}, 17 | "expected": {"Effect": "Allow", "NotResource": ["arn:aws:ec2:*:*:volume/*"]}, 18 | }, 19 | } 20 | 21 | 22 | @pytest.mark.parametrize("_, scenario", ENSURE_LIST_SCENARIOS.items()) 23 | def test_ensure_list(_, scenario): 24 | assert Statement(**scenario["input"]).policy_json() == json.dumps(scenario["expected"]) 25 | 26 | 27 | def test_ensure_condition_value_list(): 28 | subject = Statement( 29 | **{ 30 | "Effect": "Allow", 31 | "Condition": {"ArnEquals": {"ec2:SourceInstanceARN": "arn:aws:ec2:*:*:instance/instance-id"}}, 32 | } 33 | ) 34 | 35 | assert subject.policy_json() == json.dumps( 36 | { 37 | "Effect": "Allow", 38 | "Condition": {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 39 | } 40 | ) 41 | 42 | 43 | def ensure_principal_dict(): 44 | subject = Statement(**{"Effect": "Allow", "Prinncipal": "*"}) 45 | 46 | assert subject.policy_json() == json.dumps({"Effect": "Allow", "Principal": {"AWS": ["*"]}}) 47 | 48 | 49 | def ensure_principal_dict_list(): 50 | subject = Statement(**{"Effect": "Allow", "Prinncipal": {"AWS": ["*"]}}) 51 | 52 | assert subject.policy_json() == json.dumps({"Effect": "Allow", "Principal": {"AWS": ["*"]}}) 53 | -------------------------------------------------------------------------------- /tests/unit/test_statement.py: -------------------------------------------------------------------------------- 1 | from policyglass import ( 2 | Action, 3 | EffectiveAction, 4 | EffectivePrincipal, 5 | EffectiveResource, 6 | PolicyShard, 7 | Principal, 8 | Resource, 9 | Statement, 10 | ) 11 | from policyglass.condition import Condition, EffectiveCondition 12 | 13 | 14 | def test_policy_shards(): 15 | statement = Statement(**{"Effect": "Allow", "Action": "s3:*", "Principal": "*", "Resource": "*"}) 16 | 17 | assert statement.policy_shards == [ 18 | PolicyShard( 19 | effect="Allow", 20 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 21 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 22 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 23 | ) 24 | ] 25 | 26 | 27 | def test_policy_shards_not_resource_condition(): 28 | statement = Statement( 29 | **{ 30 | "Effect": "Deny", 31 | "Action": [ 32 | "s3:PutObject", 33 | ], 34 | "NotResource": "arn:aws:s3:::examplebucket/*", 35 | "Condition": {"StringNotEquals": {"s3:x-amz-server-side-encryption": "AES256"}}, 36 | } 37 | ) 38 | 39 | assert statement.policy_shards == [ 40 | PolicyShard( 41 | effect="Deny", 42 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 43 | effective_resource=EffectiveResource( 44 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 45 | ), 46 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 47 | effective_condition=EffectiveCondition( 48 | frozenset( 49 | {Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"])} 50 | ) 51 | ), 52 | ) 53 | ] 54 | -------------------------------------------------------------------------------- /tests/unit/union/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction 4 | 5 | 6 | def test_bad_union(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveAction(Action("S3:*")).union(Action("s3:*")) 9 | 10 | assert "Cannot union EffectiveAction with Action" in str(ex.value) 11 | 12 | 13 | def test_union_simple(): 14 | assert EffectiveAction(Action("s3:*")).union(EffectiveAction(Action("s3:getObject"))) == [ 15 | EffectiveAction(Action("s3:*")) 16 | ] 17 | 18 | 19 | def test_union_excluded_action_addition(): 20 | """If we have an inclusion that is a subset of another EffectiveAction's exclusions it must not be eliminated. 21 | This is because it represents an additional allow which wasn't subject to the same exclusion in its original 22 | statement. If it had been then it would have self-destructed by its own exclusions. 23 | """ 24 | a = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 25 | b = EffectiveAction(Action("s3:getObject")) 26 | 27 | assert a.union(b) == [ 28 | EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})), 29 | EffectiveAction(Action("s3:getObject")), 30 | ] 31 | 32 | 33 | def test_union_complex_overlap(): 34 | """If we have an exclusion that is a subset of another EffectiveAction's exclusions it should be eliminated. 35 | This is because it represents a smaller set of exclusions overall. 36 | """ 37 | a = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 38 | b = EffectiveAction(Action("s3:*"), frozenset({Action("s3:getobject")})) 39 | 40 | assert a.union(b) == [ 41 | EffectiveAction(Action("s3:*"), frozenset({Action("s3:getobject")})), 42 | ] 43 | 44 | 45 | def test_union_disjoint(): 46 | a = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 47 | b = EffectiveAction(Action("ec2:get*")) 48 | 49 | assert a.union(b) == [ 50 | EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})), 51 | EffectiveAction(Action("ec2:get*")), 52 | ] 53 | -------------------------------------------------------------------------------- /tests/unit/test_effective_condition.py: -------------------------------------------------------------------------------- 1 | from policyglass import Condition, EffectiveCondition 2 | 3 | 4 | def test_factory(): 5 | 6 | subject = EffectiveCondition( 7 | inclusions=frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}), 8 | exclusions=frozenset( 9 | { 10 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 11 | Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 12 | }, 13 | ), 14 | ) 15 | 16 | assert subject == EffectiveCondition( 17 | frozenset( 18 | { 19 | Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 20 | Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 21 | } 22 | ), 23 | frozenset( 24 | { 25 | Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 26 | }, 27 | ), 28 | ) 29 | 30 | 31 | def test_reverse(): 32 | 33 | subject = EffectiveCondition( 34 | inclusions=frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}), 35 | exclusions=frozenset( 36 | { 37 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 38 | Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 39 | }, 40 | ), 41 | ) 42 | 43 | assert subject.reverse == EffectiveCondition( 44 | frozenset( 45 | { 46 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 47 | Condition(key="aws:PrincipalOrgId", operator="StringEquals", values=["o-123456"]), 48 | Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 49 | } 50 | ) 51 | ) 52 | -------------------------------------------------------------------------------- /tests/unit/difference/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction 4 | 5 | 6 | def test_bad_difference(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveAction(Action("S3:*")).difference(Action("S3:*")) 9 | 10 | assert "Cannot diff EffectiveAction with Action" in str(ex.value) 11 | 12 | 13 | DIFFERENCE_SCENARIOS = { 14 | "proper_subset": { 15 | "first": EffectiveAction(Action("S3:*")), 16 | "second": EffectiveAction(Action("S3:get*")), 17 | "result": [EffectiveAction(Action("S3:*"), frozenset({Action("S3:get*")}))], 18 | }, 19 | "proper_subset_with_exclusions": { 20 | "first": EffectiveAction(Action("S3:*")), 21 | "second": EffectiveAction(Action("S3:get*"), frozenset({Action("S3:GetObject")})), 22 | "result": [ 23 | EffectiveAction(Action("S3:*"), frozenset({Action("S3:get*")})), 24 | EffectiveAction(Action("S3:GetObject")), 25 | ], 26 | }, 27 | "excluded_proper_subset": { 28 | "first": EffectiveAction(Action("S3:*"), frozenset({Action("S3:get*")})), 29 | "second": EffectiveAction(Action("S3:get*")), 30 | "result": [EffectiveAction(Action("S3:*"), frozenset({Action("S3:get*")}))], 31 | }, 32 | "subset": { 33 | "first": EffectiveAction(Action("S3:*")), 34 | "second": EffectiveAction(Action("S3:*")), 35 | "result": [], 36 | }, 37 | "subset_with_exclusion": { 38 | "first": EffectiveAction(Action("S3:*")), 39 | "second": EffectiveAction(Action("S3:*"), frozenset({Action("S3:GetObject")})), 40 | "result": [EffectiveAction(Action("S3:GetObject"))], 41 | }, 42 | "disjoint": { 43 | "first": EffectiveAction(Action("S3:*")), 44 | "second": EffectiveAction(Action("EC2:*")), 45 | "result": [EffectiveAction(Action("S3:*"))], 46 | }, 47 | } 48 | 49 | 50 | @pytest.mark.parametrize("_, scenario", DIFFERENCE_SCENARIOS.items()) 51 | def test_difference(_, scenario): 52 | first, second, result = scenario.values() 53 | assert first.difference(second) == result 54 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yml: -------------------------------------------------------------------------------- 1 | name: PolicyGlass Linting & Testing 2 | 3 | on: [push] 4 | 5 | jobs: 6 | linting-and-type-checking: 7 | runs-on: ubuntu-latest 8 | env: 9 | AWS_REGION: eu-west-2 10 | AWS_DEFAULT_REGION: eu-west-2 11 | strategy: 12 | matrix: 13 | python-version: [3.6, 3.7, 3.8, 3.9] 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements.txt 26 | - name: Type check with mypy 27 | run: | 28 | mypy 29 | - name: Lint with flake8 30 | run: | 31 | flake8 --count --statistics 32 | - name: Lint with pydocstyle 33 | run: | 34 | pydocstyle 35 | - name: Test with doctest 36 | run: | 37 | make doctest 38 | unit-and-integration-testing: 39 | runs-on: ubuntu-latest 40 | env: 41 | AWS_REGION: eu-west-2 42 | AWS_DEFAULT_REGION: eu-west-2 43 | strategy: 44 | matrix: 45 | python-version: [3.6, 3.7, 3.8, 3.9] 46 | steps: 47 | - uses: actions/checkout@v2 48 | with: 49 | fetch-depth: 0 50 | - name: Set up Python ${{ matrix.python-version }} 51 | uses: actions/setup-python@v2 52 | with: 53 | python-version: ${{ matrix.python-version }} 54 | - name: Install dependencies 55 | run: | 56 | pip install --upgrade pip 57 | pip install build -r requirements.txt 58 | - name: Build a tarball 59 | run: >- 60 | python -m 61 | build 62 | --sdist 63 | --wheel 64 | --outdir build_test/ 65 | - name: Install the tarball to ensure packaging works 66 | run: pip install $(find build_test -name "*.tar.gz") 67 | - name: Test with pytest 68 | run: | 69 | pytest 70 | -------------------------------------------------------------------------------- /tests/unit/pydantic_behaviour/test_policy.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | 5 | from policyglass import Policy, RawConditionCollection 6 | 7 | POLICIES = { 8 | "simple_iam_policy_strings": { 9 | "Version": "2012-10-17", 10 | "Statement": [ 11 | { 12 | "Effect": "Allow", 13 | "Action": ["ec2:AttachVolume"], 14 | "Resource": ["arn:aws:ec2:*:*:volume/*"], 15 | } 16 | ], 17 | }, 18 | "simple_iam_policy_lists": { 19 | "Version": "2012-10-17", 20 | "Statement": [ 21 | { 22 | "Effect": "Allow", 23 | "Action": ["ec2:AttachVolume"], 24 | "Resource": ["arn:aws:ec2:*:*:volume/*"], 25 | } 26 | ], 27 | }, 28 | "complex_iam_policy": { 29 | "Version": "2012-10-17", 30 | "Statement": [ 31 | { 32 | "Effect": "Allow", 33 | "Action": ["ec2:AttachVolume"], 34 | "Resource": ["arn:aws:ec2:*:*:volume/*"], 35 | "Condition": {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 36 | } 37 | ], 38 | }, 39 | "complex_resource_policy": { 40 | "Version": "2012-10-17", 41 | "Statement": [ 42 | { 43 | "Effect": "Allow", 44 | "Action": ["s3:PutObject", "s3:PutObjectAcl"], 45 | "Resource": ["arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"], 46 | "Principal": {"AWS": ["arn:aws:iam::111122223333:root", "arn:aws:iam::444455556666:root"]}, 47 | "Condition": {"StringEquals": {"s3:x-amz-acl": ["public-read"]}}, 48 | } 49 | ], 50 | }, 51 | } 52 | 53 | 54 | @pytest.mark.parametrize("_, policy", POLICIES.items()) 55 | def test_policy_json_equality(_, policy): 56 | assert Policy(**policy).policy_json() == json.dumps(policy) 57 | 58 | 59 | @pytest.mark.parametrize("_, policy", POLICIES.items()) 60 | def test_policy_types(_, policy): 61 | subject = Policy(**policy).statement[0].condition 62 | 63 | assert isinstance(subject, RawConditionCollection) or subject is None 64 | -------------------------------------------------------------------------------- /tests/unit/intersection/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction 4 | 5 | 6 | def test_bad_intersection(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveAction(Action("S3:*")).intersection(Action("S3:*")) 9 | 10 | assert "Cannot intersect EffectiveAction with Action" in str(ex.value) 11 | 12 | 13 | INTERSECTION_SCENARIOS = { 14 | "proper_subset": { 15 | "first": EffectiveAction(Action("S3:*")), 16 | "second": EffectiveAction(Action("S3:get*")), 17 | "result": EffectiveAction(Action("S3:get*")), 18 | }, 19 | "proper_subset_with_exclusions": { 20 | "first": EffectiveAction(Action("S3:*")), 21 | "second": EffectiveAction(Action("S3:get*"), frozenset({Action("S3:GetObject")})), 22 | "result": EffectiveAction(Action("S3:get*"), frozenset({Action("S3:GetObject")})), 23 | }, 24 | "excluded_proper_subset": { 25 | "first": EffectiveAction(Action("S3:*"), frozenset({Action("S3:get*")})), 26 | "second": EffectiveAction(Action("S3:get*")), 27 | "result": None, 28 | }, 29 | "subset": { 30 | "first": EffectiveAction(Action("S3:*")), 31 | "second": EffectiveAction(Action("S3:*")), 32 | "result": EffectiveAction(Action("S3:*")), 33 | }, 34 | "disjoint": { 35 | "first": EffectiveAction(Action("S3:*")), 36 | "second": EffectiveAction(Action("EC2:*")), 37 | "result": None, 38 | }, 39 | "larger": { 40 | "first": EffectiveAction(Action("S3:Get*")), 41 | "second": EffectiveAction(Action("S3:*")), 42 | "result": EffectiveAction(Action("S3:Get*")), 43 | }, 44 | "larger_with_exclusion": { 45 | "first": EffectiveAction(Action("S3:Get*")), 46 | "second": EffectiveAction(Action("S3:*"), frozenset({Action("S3:GetObject")})), 47 | "result": EffectiveAction(Action("S3:Get*"), frozenset({Action("S3:GetObject")})), 48 | }, 49 | } 50 | 51 | 52 | @pytest.mark.parametrize("_, scenario", INTERSECTION_SCENARIOS.items()) 53 | def test_intersection(_, scenario): 54 | first, second, result = scenario.values() 55 | assert first.intersection(second) == result 56 | -------------------------------------------------------------------------------- /tests/unit/union/test_effective_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectivePrincipal, Principal 4 | 5 | 6 | def test_bad_union(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")).union( 9 | Principal("AWS", "arn:aws:iam::123456789012:root") 10 | ) 11 | 12 | assert "Cannot union EffectivePrincipal with Principal" in str(ex.value) 13 | 14 | 15 | def test_union_simple(): 16 | assert EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")).union( 17 | EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")) 18 | ) == [EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root"))] 19 | 20 | 21 | def test_union_excluded_principal_addition(): 22 | """If we have an inclusion that is a subset of another EffectivePrincipal's exclusions it must not be eliminated. 23 | This is because it represents an additional allow which wasn't subject to the same exclusion in its original 24 | statement. If it had been then it would have self-destructed by its own exclusions. 25 | """ 26 | a = EffectivePrincipal(Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")})) 27 | b = EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")) 28 | 29 | assert a.union(b) == [ 30 | EffectivePrincipal(Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")})), 31 | EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")), 32 | ] 33 | 34 | 35 | def test_union_disjoint(): 36 | a = EffectivePrincipal( 37 | Principal("AWS", "arn:aws:iam::123456789012:root"), 38 | frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")}), 39 | ) 40 | b = EffectivePrincipal(Principal("AWS", "arn:aws:iam::098765432109:root")) 41 | 42 | assert a.union(b) == [ 43 | EffectivePrincipal( 44 | Principal("AWS", "arn:aws:iam::123456789012:root"), 45 | frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")}), 46 | ), 47 | EffectivePrincipal(Principal("AWS", "arn:aws:iam::098765432109:root")), 48 | ] 49 | -------------------------------------------------------------------------------- /tests/unit/intersection/test_effective_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectivePrincipal, Principal 4 | 5 | 6 | def test_bad_intersection(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectivePrincipal(Principal("AWS", "*")).intersection(Principal("AWS", "*")) 9 | 10 | assert "Cannot intersect EffectivePrincipal with Principal" in str(ex.value) 11 | 12 | 13 | INTERSECTION_SCENARIOS = { 14 | "proper_subset": { 15 | "first": EffectivePrincipal(Principal("AWS", "*")), 16 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 17 | "result": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 18 | }, 19 | "proper_subset_with_exclusions": { 20 | "first": EffectivePrincipal(Principal("AWS", "*")), 21 | "second": EffectivePrincipal( 22 | Principal("AWS", "arn:aws:iam::123456789012:root"), 23 | frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")}), 24 | ), 25 | "result": EffectivePrincipal( 26 | Principal("AWS", "arn:aws:iam::123456789012:root"), 27 | frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")}), 28 | ), 29 | }, 30 | "excluded_proper_subset": { 31 | "first": EffectivePrincipal( 32 | Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")}) 33 | ), 34 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 35 | "result": None, 36 | }, 37 | "subset": { 38 | "first": EffectivePrincipal(Principal("AWS", "*")), 39 | "second": EffectivePrincipal(Principal("AWS", "*")), 40 | "result": EffectivePrincipal(Principal("AWS", "*")), 41 | }, 42 | "disjoint": { 43 | "first": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 44 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::098765432109:root")), 45 | "result": None, 46 | }, 47 | } 48 | 49 | 50 | @pytest.mark.parametrize("_, scenario", INTERSECTION_SCENARIOS.items()) 51 | def test_intersection(_, scenario): 52 | first, second, result = scenario.values() 53 | assert first.intersection(second) == result 54 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_effective_action.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction 4 | 5 | 6 | def test_bad_issubset(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveAction(Action("S3:*")).issubset(Action("s3:*")) 9 | 10 | assert "Cannot compare EffectiveAction and Action" in str(ex.value) 11 | 12 | 13 | def test_issubset_simple_false(): 14 | assert not EffectiveAction(Action("s3:*")).issubset(EffectiveAction(Action("s3:getObject"))) 15 | 16 | 17 | def test_issubset_simple_true(): 18 | assert EffectiveAction(Action("s3:getObject")).issubset(EffectiveAction(Action("s3:getObject"))) 19 | 20 | 21 | def test_issubset_exclusion_true(): 22 | assert EffectiveAction(Action("s3:*"), frozenset({Action("s3:getObject")})).issubset( 23 | EffectiveAction(Action("s3:*"), frozenset({Action("s3:getObject")})) 24 | ) 25 | 26 | 27 | def test_issubset_excluded_action(): 28 | a = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 29 | b = EffectiveAction(Action("s3:getObject")) 30 | 31 | assert not a.issubset(b) 32 | 33 | 34 | def test_issubset_disjoint(): 35 | a = EffectiveAction(Action("ec2:get*")) 36 | b = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 37 | 38 | assert not a.issubset(b) 39 | 40 | 41 | def test_union_complex_overlap(): 42 | """If we have an exclusion that is a subset of another EffectiveAction's exclusions then the effective 43 | action is not a subset because it allows things the other doesn't. 44 | """ 45 | a = EffectiveAction(Action("s3:*"), frozenset({Action("s3:getobject")})) 46 | b = EffectiveAction(Action("s3:*"), frozenset({Action("s3:get*")})) 47 | 48 | assert a.issubset(b) is False 49 | 50 | 51 | def test_no_exclusion_not_subset_of_exclusion(): 52 | 53 | a = EffectiveAction(inclusion=Action("*"), exclusions=frozenset()) 54 | b = EffectiveAction(inclusion=Action("*"), exclusions=frozenset({Action("s3:getobject")})) 55 | assert a.issubset(b) is False 56 | 57 | 58 | def test_exclusion_subset_of_no_exclusion(): 59 | 60 | a = EffectiveAction(inclusion=Action("*"), exclusions=frozenset({Action("s3:getobject")})) 61 | b = EffectiveAction(inclusion=Action("*"), exclusions=frozenset()) 62 | assert a.issubset(b) 63 | -------------------------------------------------------------------------------- /policyglass/action.py: -------------------------------------------------------------------------------- 1 | """Action class.""" 2 | from fnmatch import fnmatch 3 | 4 | from .effective_arp import EffectiveARP 5 | from .models import CaseInsensitiveString 6 | 7 | 8 | class Action(CaseInsensitiveString): 9 | """Actions are case insensitive. 10 | 11 | .. epigraph:: 12 | 13 | "The prefix and the action name are case insensitive" 14 | 15 | -- `IAM JSON policy elements: Action 16 | `__ 17 | """ 18 | 19 | def issubset(self, other: object) -> bool: 20 | """Whether this object contains all the elements of another object (i.e. is a subset of the other object). 21 | 22 | Parameters: 23 | other: The object to determine if our object contains. 24 | 25 | Raises: 26 | ValueError: If the other object is not of the same type as this object. 27 | """ 28 | if not isinstance(other, self.__class__): 29 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 30 | return fnmatch(self.lower(), other.lower()) 31 | 32 | def __lt__(self, other: object) -> bool: 33 | """Whether this object contains but is not equal to (i.e. a proper subset) another object. 34 | 35 | Parameters: 36 | other: The object to determine if our object contains (but is not equal to). 37 | 38 | Raises: 39 | ValueError: If the other object is not of the same type as this object. 40 | """ 41 | if not isinstance(other, self.__class__): 42 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 43 | if self == other: 44 | return False 45 | return self.issubset(other) 46 | 47 | def __contains__(self, other: object) -> bool: 48 | """Not Implemented. 49 | 50 | Parameters: 51 | other: The object to see if this object contains. 52 | 53 | Raises: 54 | NotImplementedError: This method is not implemented. 55 | """ 56 | raise NotImplementedError() 57 | 58 | 59 | class EffectiveAction(EffectiveARP[Action]): 60 | """EffectiveActions are the representation of the difference between an Action and its exclusion. 61 | 62 | The allowed actions is the difference (subtraction) of the excluded Actions 63 | from the included action. 64 | """ 65 | 66 | _arp_type = Action 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | doc_build 131 | .vscode 132 | workspace.code-workspace 133 | /*.png 134 | .DS_Store 135 | *.gv 136 | *.gv.png 137 | *.tar.gz 138 | -------------------------------------------------------------------------------- /policyglass/resource.py: -------------------------------------------------------------------------------- 1 | """Resource class.""" 2 | from fnmatch import fnmatchcase 3 | from typing import List 4 | 5 | from .effective_arp import EffectiveARP 6 | 7 | 8 | class Resource(str): 9 | """A resource ARN may be case sensitive or case insensitive depending on the resource type.""" 10 | 11 | def __lt__(self, other: object) -> bool: 12 | """Whether this object contains (but is not equal to) another object. 13 | 14 | Parameters: 15 | other: The object to determine if our object contains. 16 | 17 | Raises: 18 | ValueError: If the other object is not of the same type as this object. 19 | """ 20 | if not isinstance(other, self.__class__): 21 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 22 | if self == other: 23 | return False 24 | return fnmatchcase(self, other) 25 | 26 | def issubset(self, other: object) -> bool: 27 | """Whether this object contains all the elements of another object (i.e. is a subset of the other object). 28 | 29 | Parameters: 30 | other: The object to determine if our object contains. 31 | 32 | Raises: 33 | ValueError: If the other object is not of the same type as this object. 34 | """ 35 | if not isinstance(other, self.__class__): 36 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 37 | for self_element, other_element in zip(self.arn_elements, other.arn_elements): 38 | if not fnmatchcase(self_element, other_element): 39 | return False 40 | return True 41 | 42 | @property 43 | def arn_elements(self) -> List[str]: 44 | """Return a list of arn elements, replacing blanks with ``*``.""" 45 | return [element or "*" for element in self.split(":")] 46 | 47 | def __contains__(self, other: object) -> bool: 48 | """Not Implemented. 49 | 50 | Parameters: 51 | other: The object to see if this object contains. 52 | 53 | Raises: 54 | NotImplementedError: This method is not implemented. 55 | """ 56 | raise NotImplementedError() 57 | 58 | def __repr__(self) -> str: 59 | """Return an instantiable representation of this object.""" 60 | return f"{self.__class__.__name__}('{self}')" 61 | 62 | 63 | class EffectiveResource(EffectiveARP[Resource]): 64 | """EffectiveResources are the representation of the difference between an Resource and its exclusion. 65 | 66 | The allowed Resource is the difference (subtraction) of the excluded Resources 67 | from the included Resource. 68 | """ 69 | 70 | _arp_type = Resource 71 | -------------------------------------------------------------------------------- /tests/unit/difference/test_effective_principal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectivePrincipal, Principal 4 | 5 | 6 | def test_bad_difference(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectivePrincipal(Principal("AWS", "*")).difference(Principal("AWS", "*")) 9 | 10 | assert "Cannot diff EffectivePrincipal with Principal" in str(ex.value) 11 | 12 | 13 | DIFFERENCE_SCENARIOS = { 14 | "proper_subset": { 15 | "first": EffectivePrincipal(Principal("AWS", "*")), 16 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 17 | "result": [ 18 | EffectivePrincipal(Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")})) 19 | ], 20 | }, 21 | "proper_subset_with_exclusions": { 22 | "first": EffectivePrincipal(Principal("AWS", "*")), 23 | "second": EffectivePrincipal( 24 | Principal("AWS", "arn:aws:iam::123456789012:root"), 25 | frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")}), 26 | ), 27 | "result": [ 28 | EffectivePrincipal(Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")})), 29 | EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")), 30 | ], 31 | }, 32 | "excluded_proper_subset": { 33 | "first": EffectivePrincipal( 34 | Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")}) 35 | ), 36 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 37 | "result": [ 38 | EffectivePrincipal(Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")})) 39 | ], 40 | }, 41 | "subset": { 42 | "first": EffectivePrincipal(Principal("AWS", "*")), 43 | "second": EffectivePrincipal(Principal("AWS", "*")), 44 | "result": [], 45 | }, 46 | "subset_with_exclusion": { 47 | "first": EffectivePrincipal(Principal("AWS", "*")), 48 | "second": EffectivePrincipal( 49 | Principal("AWS", "*"), exclusions=frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")}) 50 | ), 51 | "result": [EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root"))], 52 | }, 53 | "disjoint": { 54 | "first": EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root")), 55 | "second": EffectivePrincipal(Principal("AWS", "arn:aws:iam::098765432109:root")), 56 | "result": [EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:root"))], 57 | }, 58 | } 59 | 60 | 61 | @pytest.mark.parametrize("_, scenario", DIFFERENCE_SCENARIOS.items()) 62 | def test_difference(_, scenario): 63 | first, second, result = scenario.values() 64 | assert first.difference(second) == result 65 | -------------------------------------------------------------------------------- /tests/unit/difference/test_effective_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import EffectiveResource, Resource 4 | 5 | 6 | def test_bad_difference(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")).difference(Resource("arn:aws:ec2:*:*:volume/*")) 9 | 10 | assert "Cannot diff EffectiveResource with Resource" in str(ex.value) 11 | 12 | 13 | # ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*:*:volume/vol-12345678"] 14 | 15 | DIFFERENCE_SCENARIOS = { 16 | "proper_subset": { 17 | "first": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 18 | "second": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-123*")), 19 | "result": [ 20 | EffectiveResource( 21 | Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-123*")}) 22 | ) 23 | ], 24 | }, 25 | "proper_subset_with_exclusions": { 26 | "first": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 27 | "second": EffectiveResource( 28 | Resource("arn:aws:ec2:*:*:volume/vol-123*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-12345678")}) 29 | ), 30 | "result": [ 31 | EffectiveResource( 32 | Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-123*")}) 33 | ), 34 | EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-12345678")), 35 | ], 36 | }, 37 | "excluded_proper_subset": { 38 | "first": EffectiveResource( 39 | Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-123*")}) 40 | ), 41 | "second": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/vol-123*")), 42 | "result": [ 43 | EffectiveResource( 44 | Resource("arn:aws:ec2:*:*:volume/*"), frozenset({Resource("arn:aws:ec2:*:*:volume/vol-123*")}) 45 | ) 46 | ], 47 | }, 48 | "subset": { 49 | "first": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 50 | "second": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 51 | "result": [], 52 | }, 53 | "subset_with_exclusion": { 54 | "first": EffectiveResource(Resource("*")), 55 | "second": EffectiveResource(Resource("*"), exclusions=frozenset({Resource("arn:aws:ec2:*:*:volume/*")})), 56 | "result": [EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"))], 57 | }, 58 | "disjoint": { 59 | "first": EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*")), 60 | "second": EffectiveResource(Resource("EC2:*")), 61 | "result": [EffectiveResource(Resource("arn:aws:ec2:*:*:volume/*"))], 62 | }, 63 | } 64 | 65 | 66 | @pytest.mark.parametrize("_, scenario", DIFFERENCE_SCENARIOS.items()) 67 | def test_difference(_, scenario): 68 | first, second, result = scenario.values() 69 | assert first.difference(second) == result 70 | -------------------------------------------------------------------------------- /doc_source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | import doctest 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import re 17 | import sys 18 | from pathlib import Path 19 | 20 | sys.path.insert(0, os.path.abspath("..")) 21 | 22 | 23 | # -- Project information ----------------------------------------------------- 24 | 25 | project = "PolicyGlass" 26 | copyright = "2021, Sam Martin" 27 | author = "Sam Martin" 28 | 29 | # The full version, including alpha/beta/rc tags 30 | with open(Path(__file__).parent.parent / Path("setup.py")) as f: 31 | 32 | release = re.search(r"version=\"([^\"]+)\"", f.read()).groups()[0] 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.doctest", 42 | "sphinx.ext.autodoc", 43 | "sphinx_rtd_theme", 44 | "sphinx.ext.autodoc.typehints", 45 | "sphinx.ext.napoleon", 46 | "sphinx.ext.intersphinx", 47 | ] 48 | 49 | # Add any paths that contain templates here, relative to this directory. 50 | templates_path = ["_templates"] 51 | 52 | # List of patterns, relative to source directory, that match files and 53 | # directories to ignore when looking for source files. 54 | # This pattern also affects html_static_path and html_extra_path. 55 | exclude_patterns = [] 56 | 57 | 58 | # -- Options for HTML output ------------------------------------------------- 59 | 60 | # The theme to use for HTML and HTML Help pages. See the documentation for 61 | # a list of builtin themes. 62 | # 63 | html_theme = "sphinx_rtd_theme" 64 | 65 | # Add any paths that contain custom static files (such as style sheets) here, 66 | # relative to this directory. They are copied after the builtin static files, 67 | # so a file named "default.css" will overwrite the builtin "default.css". 68 | html_static_path = ["_static"] 69 | 70 | html_favicon = "logo.png" 71 | 72 | 73 | # -- Doctest 74 | doctest_default_flags = doctest.NORMALIZE_WHITESPACE 75 | 76 | # -- Autodoc 77 | add_module_names = False 78 | autodoc_typehints = "description" 79 | autodoc_default_options = { 80 | "special-members": "__init__", 81 | "undoc-members": True, 82 | } 83 | 84 | nitpicky = True 85 | nitpick_ignore = [ 86 | ("py:class", "policyglass.statement.T"), 87 | ("py:class", "policyglass.effective_arp.T"), 88 | ("py:class", "policyglass.effective_arp.EffectiveARP"), 89 | ] 90 | 91 | # -- Intersphinx 92 | intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} 93 | -------------------------------------------------------------------------------- /doc_source/class_reference/understanding_effective_conditions.rst: -------------------------------------------------------------------------------- 1 | Understanding Effective Conditions 2 | =================================== 3 | 4 | Policy conditions, when they exist, are always restrictions on the scenarios in which a policy applies. 5 | Every :class:`~policyglass.policy_shard.PolicyShard` object will have a :class:`~policyglass.condition.EffectiveCondition` 6 | object, even if the :class:`~policyglass.condition.EffectiveCondition` has no ``inclusions`` or ``exclusions`` specified. 7 | 8 | What is an inclusion/exclusion? 9 | --------------------------------- 10 | 11 | An :class:`~policyglass.condition.EffectiveCondition` ``inclusion`` is a :class:`~policyglass.condition.Condition` which 12 | must be true, for a :class:`~policyglass.policy_shard.PolicyShard` to apply. 13 | An :class:`~policyglass.condition.EffectiveCondition` ``exclusion`` is a :class:`~policyglass.condition.Condition` which 14 | must be **false**, for a :class:`~policyglass.policy_shard.PolicyShard` to apply. 15 | 16 | .. doctest:: 17 | 18 | >>> from policyglass import PolicyShard, EffectiveAction, Action, EffectiveResource, Resource, EffectivePrincipal, Principal, EffectiveCondition, Condition 19 | >>> effective_condition = EffectiveCondition( 20 | ... inclusions=frozenset({ 21 | ... Condition("aws:PrincipalOrgId", "StringEquals", ["o-123456"]), 22 | ... }), 23 | ... exclusions=frozenset({ 24 | ... Condition(key="TestKey", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]) 25 | ... }), 26 | ... ) 27 | >>> policy_shard = PolicyShard( 28 | ... effect="Allow", 29 | ... effective_action=EffectiveAction(Action("*")), 30 | ... effective_resource=EffectiveResource(Resource("*")), 31 | ... effective_principal=EffectivePrincipal(Principal("AWS", "*")), 32 | ... effective_condition=effective_condition 33 | ... ) 34 | 35 | This ``effective_condition``'s ``inclusions`` dictate that for ``Action``, ``Resource`` and ``Principal`` to be allowed, then at the time the API call takes place the 36 | following be true: 37 | 38 | #. ``aws:PrincipalOrgId`` must ``StringEquals`` a value of ``o-123456``. 39 | #. ``TestKey`` must **NOT** ``BinaryEquals`` a value of ``QmluYXJ5VmFsdWVJbkJhc2U2NA==`` 40 | 41 | When would an exclusion occur? 42 | -------------------------------- 43 | 44 | An :class:`~policyglass.condition.EffectiveCondition` ``exclusion`` is quite a rare phenomenon. 45 | Normally when ``Deny`` :class:`~policyglass.policy_shard.PolicyShard` conditions are folded into 46 | ``Allow`` :class:`~policyglass.policy_shard.PolicyShard` objects, they are reversed using the 47 | :attr:`~policyglass.condition.Condition.reverse` attribute. 48 | 49 | For example ``StringNotEquals`` on a ``Deny`` PolicyShard will become ``StringEquals`` on an ``Allow`` PolicyShard. 50 | This simplifies the intelligibility of the ``Allow`` shards significantly. 51 | 52 | When a ``Deny`` statement has a condition that cannot be reversed (e.g. ``BinaryEquals`` for which there is no corresponding ``BinaryNotEquals``) 53 | then the condition must be placed into the ``exclusions`` of the :attr:`~policyglass.policy_shard.PolicyShard.effective_condition` of the ``Allow`` PolicyShard. 54 | -------------------------------------------------------------------------------- /tests/unit/test_policy_shards_explain.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import PolicyShard, explain_policy_shards 4 | from policyglass.action import Action, EffectiveAction 5 | from policyglass.condition import Condition, EffectiveCondition 6 | from policyglass.principal import EffectivePrincipal, Principal 7 | from policyglass.resource import EffectiveResource, Resource 8 | 9 | 10 | def test_policy_shard_explain_attribute(): 11 | shard = PolicyShard( 12 | effect="Allow", 13 | effective_action=EffectiveAction(inclusion=Action("s3:Get*"), exclusions=frozenset({Action("s3:GetObject")})), 14 | effective_resource=EffectiveResource( 15 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*")}) 16 | ), 17 | effective_principal=EffectivePrincipal( 18 | inclusion=Principal(type="AWS", value="*"), 19 | exclusions=frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/role-name")}), 20 | ), 21 | effective_condition=EffectiveCondition( 22 | inclusions=frozenset({Condition("s3:x-amz-server-side-encryption", "StringNotEquals", ["AES256"])}), 23 | exclusions=frozenset({Condition("key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 24 | ), 25 | ) 26 | 27 | assert ( 28 | shard.explain == "Allow action s3:Get* (except for s3:GetObject) on resource * " 29 | "(except for arn:aws:s3:::DOC-EXAMPLE-BUCKET/*) with principal AWS * " 30 | "(except principals AWS arn:aws:iam::123456789012:role/role-name). " 31 | "Provided conditions s3:x-amz-server-side-encryption StringNotEquals ['AES256'] are met. " 32 | "Unless conditions key BinaryEquals ['QmluYXJ5VmFsdWVJbkJhc2U2NA=='] are met." 33 | ) 34 | 35 | 36 | def test_explain_policy_shards_function(): 37 | shard = PolicyShard( 38 | effect="Allow", 39 | effective_action=EffectiveAction(inclusion=Action("s3:Get*"), exclusions=frozenset({Action("s3:GetObject")})), 40 | effective_resource=EffectiveResource( 41 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*")}) 42 | ), 43 | effective_principal=EffectivePrincipal( 44 | inclusion=Principal(type="AWS", value="*"), 45 | exclusions=frozenset({Principal("AWS", "arn:aws:iam::123456789012:role/role-name")}), 46 | ), 47 | effective_condition=EffectiveCondition( 48 | inclusions=frozenset({Condition("s3:x-amz-server-side-encryption", "StringNotEquals", ["AES256"])}), 49 | exclusions=frozenset({Condition("key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 50 | ), 51 | ) 52 | 53 | assert explain_policy_shards([shard]) == [ 54 | "Allow action s3:Get* (except for s3:GetObject) on resource * " 55 | "(except for arn:aws:s3:::DOC-EXAMPLE-BUCKET/*) with principal AWS * " 56 | "(except principals AWS arn:aws:iam::123456789012:role/role-name). " 57 | "Provided conditions s3:x-amz-server-side-encryption StringNotEquals ['AES256'] are met. " 58 | "Unless conditions key BinaryEquals ['QmluYXJ5VmFsdWVJbkJhc2U2NA=='] are met." 59 | ] 60 | 61 | 62 | def test_explain_policy_shards_supported_language(): 63 | with pytest.raises(NotImplementedError) as ex: 64 | explain_policy_shards([], language="es") 65 | assert "Language 'es' is not supported" in str(ex.value) 66 | -------------------------------------------------------------------------------- /tests/unit/test_policy_shards_to_json.py: -------------------------------------------------------------------------------- 1 | from policyglass import PolicyShard 2 | from policyglass.action import Action, EffectiveAction 3 | from policyglass.condition import Condition, EffectiveCondition 4 | from policyglass.policy_shard import policy_shards_to_json 5 | from policyglass.principal import EffectivePrincipal, Principal 6 | from policyglass.resource import EffectiveResource, Resource 7 | 8 | 9 | def test_policy_shards_to_json(): 10 | shards = [ 11 | PolicyShard( 12 | effect="Allow", 13 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:Get*")})), 14 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 15 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 16 | ), 17 | PolicyShard( 18 | effect="Allow", 19 | effective_action=EffectiveAction(inclusion=Action("s3:GetObject"), exclusions=frozenset()), 20 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 21 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 22 | ), 23 | PolicyShard( 24 | effect="Allow", 25 | effective_action=EffectiveAction( 26 | inclusion=Action("s3:Get*"), exclusions=frozenset({Action("s3:GetObject")}) 27 | ), 28 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 29 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 30 | effective_condition=EffectiveCondition( 31 | exclusions=frozenset({Condition("key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}) 32 | ), 33 | ), 34 | ] 35 | 36 | assert ( 37 | policy_shards_to_json(shards, exclude_defaults=True, indent=2) 38 | == """[ 39 | { 40 | "effective_action": { 41 | "inclusion": "s3:*", 42 | "exclusions": [ 43 | "s3:Get*" 44 | ] 45 | }, 46 | "effective_resource": { 47 | "inclusion": "*" 48 | }, 49 | "effective_principal": { 50 | "inclusion": { 51 | "type": "AWS", 52 | "value": "*" 53 | } 54 | } 55 | }, 56 | { 57 | "effective_action": { 58 | "inclusion": "s3:GetObject" 59 | }, 60 | "effective_resource": { 61 | "inclusion": "*" 62 | }, 63 | "effective_principal": { 64 | "inclusion": { 65 | "type": "AWS", 66 | "value": "*" 67 | } 68 | } 69 | }, 70 | { 71 | "effective_action": { 72 | "inclusion": "s3:Get*", 73 | "exclusions": [ 74 | "s3:GetObject" 75 | ] 76 | }, 77 | "effective_resource": { 78 | "inclusion": "*" 79 | }, 80 | "effective_principal": { 81 | "inclusion": { 82 | "type": "AWS", 83 | "value": "*" 84 | } 85 | }, 86 | "effective_condition": { 87 | "exclusions": [ 88 | { 89 | "key": "key", 90 | "operator": "BinaryEquals", 91 | "values": [ 92 | "QmluYXJ5VmFsdWVJbkJhc2U2NA==" 93 | ] 94 | } 95 | ] 96 | } 97 | } 98 | ]""" 99 | ) 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at samjackmartin+cloudwanderer@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/unit/equality/test_effective_condition.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Condition, EffectiveCondition 4 | 5 | EFFECTIVE_CONDITION_SCENARIOS = { 6 | "exactly_equal": [ 7 | EffectiveCondition( 8 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 9 | frozenset({Condition("Key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 10 | ), 11 | EffectiveCondition( 12 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 13 | frozenset({Condition("Key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 14 | ), 15 | ], 16 | "equal_different_order": [ 17 | EffectiveCondition( 18 | frozenset( 19 | { 20 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 21 | Condition("Key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 22 | } 23 | ), 24 | frozenset({}), 25 | ), 26 | EffectiveCondition( 27 | frozenset( 28 | { 29 | Condition("Key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="]), 30 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 31 | } 32 | ), 33 | frozenset({}), 34 | ), 35 | ], 36 | "equal_different_case_operator": [ 37 | EffectiveCondition( 38 | frozenset( 39 | { 40 | Condition("aws:PrincipalOrgId", "stringnotequals", ["o-123456"]), 41 | } 42 | ), 43 | frozenset({}), 44 | ), 45 | EffectiveCondition( 46 | frozenset( 47 | { 48 | Condition("aws:PrincipalOrgId", "STRINGNOTEQUALS", ["o-123456"]), 49 | } 50 | ), 51 | frozenset({}), 52 | ), 53 | ], 54 | "equal_different_case_key": [ 55 | EffectiveCondition( 56 | frozenset( 57 | { 58 | Condition("aws:PRINCIPALORGID", "StringNotEquals", ["o-123456"]), 59 | } 60 | ), 61 | frozenset({}), 62 | ), 63 | EffectiveCondition( 64 | frozenset( 65 | { 66 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 67 | } 68 | ), 69 | frozenset({}), 70 | ), 71 | ], 72 | } 73 | 74 | 75 | @pytest.mark.parametrize("_, scenario", EFFECTIVE_CONDITION_SCENARIOS.items()) 76 | def test_effective_condition_equality(_, scenario): 77 | assert scenario[0] == scenario[1] 78 | 79 | 80 | EFFECTIVE_CONDITION_NOT_MATCH_SCENARIOS = { 81 | "entirely_different": [ 82 | EffectiveCondition( 83 | frozenset({Condition("Key", "BinaryEquals", ["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), frozenset() 84 | ), 85 | EffectiveCondition(frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset()), 86 | ], 87 | "different_case_value": [ 88 | EffectiveCondition( 89 | frozenset( 90 | { 91 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 92 | } 93 | ), 94 | frozenset({}), 95 | ), 96 | EffectiveCondition( 97 | frozenset( 98 | { 99 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["O-123456"]), 100 | } 101 | ), 102 | frozenset({}), 103 | ), 104 | ], 105 | } 106 | 107 | 108 | @pytest.mark.parametrize("_, scenario", EFFECTIVE_CONDITION_NOT_MATCH_SCENARIOS.items()) 109 | def test_effective_condition_inequality(_, scenario): 110 | assert scenario[0] != scenario[1] 111 | -------------------------------------------------------------------------------- /tests/unit/equality/test_policy_shard.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import PolicyShard 4 | from policyglass.action import Action, EffectiveAction 5 | from policyglass.condition import Condition, EffectiveCondition 6 | from policyglass.principal import EffectivePrincipal, Principal 7 | from policyglass.resource import EffectiveResource, Resource 8 | 9 | SHARD_MATCH_SCENARIOS = { 10 | "exactly_equal": [ 11 | [ 12 | PolicyShard( 13 | effect="Deny", 14 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 15 | effective_resource=EffectiveResource( 16 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 17 | ), 18 | effective_principal=EffectivePrincipal( 19 | inclusion=Principal(type="AWS", value="*"), exclusions=frozenset() 20 | ), 21 | effective_condition=EffectiveCondition( 22 | frozenset( 23 | { 24 | Condition( 25 | key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"] 26 | ) 27 | } 28 | ) 29 | ), 30 | ) 31 | ], 32 | [ 33 | PolicyShard( 34 | effect="Deny", 35 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 36 | effective_resource=EffectiveResource( 37 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 38 | ), 39 | effective_principal=EffectivePrincipal( 40 | inclusion=Principal(type="AWS", value="*"), exclusions=frozenset() 41 | ), 42 | effective_condition=EffectiveCondition( 43 | frozenset( 44 | { 45 | Condition( 46 | key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"] 47 | ) 48 | } 49 | ) 50 | ), 51 | ) 52 | ], 53 | ] 54 | } 55 | 56 | 57 | @pytest.mark.parametrize("_, scenario", SHARD_MATCH_SCENARIOS.items()) 58 | def test_shard_equality(_, scenario): 59 | assert scenario[0] == scenario[1] 60 | 61 | 62 | SHARD_NOT_MATCH_SCENARIOS = { 63 | "different_condition": [ 64 | PolicyShard( 65 | effect="Allow", 66 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 67 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 68 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 69 | effective_condition=EffectiveCondition( 70 | frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}) 71 | ), 72 | ), 73 | PolicyShard( 74 | effect="Allow", 75 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 76 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 77 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 78 | effective_condition=EffectiveCondition( 79 | frozenset( 80 | { 81 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 82 | } 83 | ) 84 | ), 85 | ), 86 | ], 87 | } 88 | 89 | 90 | @pytest.mark.parametrize("_, scenario", SHARD_NOT_MATCH_SCENARIOS.items()) 91 | def test_sgard_inequality(_, scenario): 92 | assert scenario[0] != scenario[1] 93 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PolicyGlass 2 | =========== 3 | 4 | .. |version| 5 | image:: https://img.shields.io/pypi/v/policyglass?style=flat-square 6 | :alt: PyPI 7 | :target: https://pypi.org/project/policyglass/ 8 | 9 | .. |checks| 10 | image:: https://img.shields.io/github/workflow/status/CloudWanderer-io/PolicyGlass/PolicyGlass%20Linting%20&%20Testing/main?style=flat-square 11 | :alt: GitHub Workflow Status (branch) 12 | :target: https://github.com/CloudWanderer-io/PolicyGlass/actions?query=branch%3Amain 13 | 14 | .. |docs| 15 | image:: https://readthedocs.org/projects/cloudwanderer/badge/?version=latest&style=flat-square 16 | :target: https://www.cloudwanderer.io/en/latest/?badge=latest 17 | :alt: Documentation Status 18 | 19 | 20 | .. image:: https://user-images.githubusercontent.com/803607/146429306-b132f7b2-79b9-44a0-a38d-f46127746c46.png 21 | 22 | |version| |checks| |docs| 23 | 24 | | **Documentation**: `policyglass.cloudwanderer.io `__ 25 | | **GitHub**: `https://github.com/CloudWanderer-io/PolicyGlass `__ 26 | 27 | PolicyGlass allows you to analyse one or more AWS policies' effective permissions in aggregate, by restating them in the form of PolicyShards which are always Allow, never Deny. 28 | 29 | PolicyGlass will **always** result in only allow ``PolicyShard`` objects, no matter how complex the policy. This makes understanding the effect of your policies programmatically a breeze. 30 | 31 | Try it out 32 | """""""""""" 33 | 34 | .. image:: https://github.com/CloudWanderer-io/PolicyGlass/blob/dbc313d065247b557e36bfb8dc7ece2684a9cc81/doc_source/images/policyglass-sandbox.gif?raw=true 35 | :alt: PolicyGlass Sandbox screenshot 36 | :target: https://sandbox.policyglass.cloudwanderer.io 37 | :height: 25em 38 | 39 | Try out custom policies quickly without installing anything with the `PolicyGlass Sandbox `__. 40 | 41 | Installation 42 | """"""""""""""" 43 | 44 | 45 | .. code-block :: 46 | 47 | pip install policyglass 48 | 49 | 50 | Usage 51 | """""""""""""""""""""""" 52 | 53 | Let's take two policies, *a* and *b* and pit them against each other. 54 | 55 | .. doctest:: 56 | 57 | >>> from policyglass import Policy, policy_shards_effect 58 | >>> policy_a = Policy(**{ 59 | ... "Version": "2012-10-17", 60 | ... "Statement": [ 61 | ... { 62 | ... "Effect": "Allow", 63 | ... "Action": [ 64 | ... "s3:*" 65 | ... ], 66 | ... "Resource": "*" 67 | ... } 68 | ... ] 69 | ... }) 70 | >>> policy_b = Policy(**{ 71 | ... "Version": "2012-10-17", 72 | ... "Statement": [ 73 | ... { 74 | ... "Effect": "Deny", 75 | ... "Action": [ 76 | ... "s3:*" 77 | ... ], 78 | ... "Resource": "arn:aws:s3:::examplebucket/*" 79 | ... } 80 | ... ] 81 | ... }) 82 | >>> policy_shards = [*policy_a.policy_shards, *policy_b.policy_shards] 83 | >>> effect = policy_shards_effect(policy_shards) 84 | >>> effect 85 | [PolicyShard(effect='Allow', 86 | effective_action=EffectiveAction(inclusion=Action('s3:*'), 87 | exclusions=frozenset()), 88 | effective_resource=EffectiveResource(inclusion=Resource('*'), 89 | exclusions=frozenset({Resource('arn:aws:s3:::examplebucket/*')})), 90 | effective_principal=EffectivePrincipal(inclusion=Principal(type='AWS', value='*'), 91 | exclusions=frozenset()), 92 | effective_condition=EffectiveCondition(inclusions=frozenset(), exclusions=frozenset()))] 93 | 94 | Two policies, two statements, resulting in a single allow ``PolicyShard``. 95 | More complex policies will result in multiple shards, but they will always be **allows**, no matter how complex the policy. 96 | 97 | You can also make them human readable! 98 | 99 | .. doctest:: 100 | 101 | >>> from policyglass import explain_policy_shards 102 | >>> explain_policy_shards(effect) 103 | ['Allow action s3:* on resource * (except for arn:aws:s3:::examplebucket/*) with principal AWS *.'] 104 | -------------------------------------------------------------------------------- /tests/unit/union/test_policy_shard.py: -------------------------------------------------------------------------------- 1 | from policyglass import PolicyShard 2 | from policyglass.action import Action, EffectiveAction 3 | from policyglass.condition import Condition, EffectiveCondition 4 | from policyglass.principal import EffectivePrincipal, Principal 5 | from policyglass.resource import EffectiveResource, Resource 6 | 7 | 8 | def test_elimination(): 9 | shard_a = PolicyShard( 10 | effect="Allow", 11 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 12 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 13 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 14 | effective_condition=EffectiveCondition(frozenset({Condition("aws:username", "StringEquals", ["johndoe"])})), 15 | ) 16 | 17 | shard_b = PolicyShard( 18 | effect="Allow", 19 | effective_action=EffectiveAction(inclusion=Action("s3:getobject"), exclusions=frozenset()), 20 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 21 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 22 | effective_condition=EffectiveCondition(frozenset({Condition("aws:username", "StringEquals", ["johndoe"])})), 23 | ) 24 | 25 | assert shard_a.union(shard_b) == [shard_a] 26 | 27 | 28 | def test_disjoint(): 29 | shard_a = PolicyShard( 30 | effect="Allow", 31 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 32 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 33 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 34 | ) 35 | 36 | shard_b = PolicyShard( 37 | effect="Allow", 38 | effective_action=EffectiveAction(inclusion=Action("ec2:*"), exclusions=frozenset()), 39 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 40 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 41 | ) 42 | 43 | assert shard_a.union(shard_b) == [shard_a, shard_b] 44 | 45 | 46 | def test_condition(): 47 | shard_a = PolicyShard( 48 | effect="Allow", 49 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 50 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 51 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 52 | ) 53 | 54 | shard_b = PolicyShard( 55 | effect="Allow", 56 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 57 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 58 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 59 | effective_condition=EffectiveCondition(frozenset({Condition("aws:username", "StringEquals", ["johndoe"])})), 60 | ) 61 | 62 | assert shard_a.union(shard_b) == [shard_a] 63 | 64 | 65 | def test_multiple_conditions(): 66 | shard_a = PolicyShard( 67 | effect="Allow", 68 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 69 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 70 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 71 | effective_condition=EffectiveCondition( 72 | frozenset( 73 | { 74 | Condition("aws:username", "StringEquals", ["johndoe"]), 75 | Condition("testkey", "testoperator", ["testvalue"]), 76 | } 77 | ) 78 | ), 79 | ) 80 | 81 | shard_b = PolicyShard( 82 | effect="Allow", 83 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 84 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 85 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 86 | effective_condition=EffectiveCondition(frozenset({Condition("aws:username", "StringEquals", ["johndoe"])})), 87 | ) 88 | 89 | assert shard_a.union(shard_b) == [shard_b] 90 | -------------------------------------------------------------------------------- /doc_source/class_reference/understanding_policy_shards.rst: -------------------------------------------------------------------------------- 1 | Understanding Policy Shards 2 | ================================== 3 | 4 | Dedupe & Merge 5 | -------------------- 6 | 7 | :class:`~policyglass.policy_shard.PolicyShard` objects need to go through two phases to correct their sizes. 8 | 9 | 1. Dedupe using :func:`~policyglass.policy_shard.dedupe_policy_shard_subsets` 10 | 2. Merge using :func:`~policyglass.policy_shard.dedupe_policy_shards` 11 | 12 | The first collapses all PolicyShards which are subsets of each other into each other, in other words eliminating 13 | all smaller PolicyShards that can fit into bigger PolicyShards. 14 | 15 | The second reverses that where necessary, identifying shards that are not subsets of each other but nonetheless 16 | have some intersection and therefore duplicate the permissions space. 17 | 18 | When does a PolicyShard intesect without being a subset? 19 | """"""""""""""""""""""""""""""""""""""""""""""""""""""""" 20 | 21 | This is a departure from EffectiveARPs (Action, Resource, Principal) objects which by contrast cannot intersect without 22 | being subsets. 23 | 24 | Let's consider this scenario 25 | 26 | .. doctest:: 27 | 28 | >>> from policyglass import PolicyShard 29 | >>> from policyglass.action import Action, EffectiveAction 30 | >>> from policyglass.condition import Condition, EffectiveCondition 31 | >>> from policyglass.principal import EffectivePrincipal, Principal 32 | >>> from policyglass.resource import EffectiveResource, Resource 33 | >>> shard_a = PolicyShard( 34 | ... effect="Allow", 35 | ... effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 36 | ... effective_resource=EffectiveResource(inclusion=Resource("*")), 37 | ... effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*")), 38 | ... effective_condition=EffectiveCondition(frozenset( 39 | ... {Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])} 40 | ... )), 41 | ... ) 42 | >>> shard_b = PolicyShard( 43 | ... effect="Allow", 44 | ... effective_action=EffectiveAction(inclusion=Action("s3:*")), 45 | ... effective_resource=EffectiveResource(inclusion=Resource("*")), 46 | ... effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*")), 47 | ... effective_condition=EffectiveCondition(frozenset( 48 | ... { 49 | ... Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 50 | ... Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 51 | ... } 52 | ... )), 53 | ... ) 54 | 55 | Shard A 56 | #. Has a single condition 57 | #. Excludes ``s3:PutObject`` 58 | 59 | Shard B 60 | #. Has two conditions, one of which is the same as Shard A 61 | #. Does not exclude ``s3:PutObject`` 62 | 63 | This means that. 64 | 65 | #. Because Shard A and Shard B both have conditions they can never be considered subsets of one another even during the decomposition process 66 | #. They do intersect because every part of ``s3:*`` apart from ``s3:PutObject`` is less restrictively allowed by Shard A 67 | #. We want to reduce the scope of Shard B to just ``s3:PutObject`` 68 | 69 | To do this we use :func:`~policyglass.policy_shard.dedupe_policy_shards` 70 | 71 | .. doctest:: 72 | 73 | >>> from policyglass.policy_shard import dedupe_policy_shards 74 | >>> shard_b_delineated, shard_a_delineated = dedupe_policy_shards([shard_a, shard_b]) 75 | >>> assert shard_a_delineated == PolicyShard( 76 | ... effect='Allow', 77 | ... effective_action=EffectiveAction(inclusion=Action('s3:*'), exclusions=frozenset({Action('s3:PutObject')})), 78 | ... effective_resource=EffectiveResource(inclusion=Resource('*')), 79 | ... effective_principal=EffectivePrincipal(inclusion=Principal(type='AWS', value='*')), 80 | ... effective_condition=EffectiveCondition(frozenset( 81 | ... {Condition(key='aws:PrincipalOrgId', operator='StringNotEquals', values=['o-123456'])} 82 | ... )), 83 | ... ) 84 | >>> assert shard_b_delineated == PolicyShard( 85 | ... effect='Allow', 86 | ... effective_action=EffectiveAction(inclusion=Action('s3:PutObject')), 87 | ... effective_resource=EffectiveResource(inclusion=Resource('*')), 88 | ... effective_principal=EffectivePrincipal(inclusion=Principal(type='AWS', value='*')), 89 | ... effective_condition=EffectiveCondition(frozenset({ 90 | ... Condition(key='aws:PrincipalOrgId', operator='StringNotEquals', values=['o-123456']), 91 | ... Condition(key='s3:x-amz-server-side-encryption', operator='StringEquals', values=['AES256']) 92 | ... })), 93 | ... ) 94 | 95 | You'll notice that the intersection has been removed, as Shard B now only has ``s3:PutObject`` as the rest of ``s3:*`` was covered by Shard A. 96 | -------------------------------------------------------------------------------- /policyglass/statement.py: -------------------------------------------------------------------------------- 1 | """Statement class.""" 2 | 3 | from typing import Dict, FrozenSet, List, Optional, Tuple, TypeVar, Union 4 | 5 | from pydantic import BaseModel, validator 6 | 7 | from .action import Action, EffectiveAction 8 | from .condition import ( 9 | Condition, 10 | ConditionKey, 11 | ConditionOperator, 12 | ConditionValue, 13 | EffectiveCondition, 14 | RawConditionCollection, 15 | ) 16 | from .policy_shard import PolicyShard 17 | from .principal import EffectivePrincipal, Principal, PrincipalCollection, PrincipalType, PrincipalValue 18 | from .resource import EffectiveResource, Resource 19 | from .utils import to_pascal 20 | 21 | T = TypeVar("T") 22 | 23 | 24 | class Effect(str): 25 | """Allow or Deny.""" 26 | 27 | ... 28 | 29 | 30 | class Statement(BaseModel): 31 | """A Policy Statement.""" 32 | 33 | effect: Effect 34 | action: Optional[List[Action]] 35 | not_action: Optional[List[Action]] 36 | resource: Optional[List[Resource]] 37 | not_resource: Optional[List[Resource]] 38 | principal: Optional[PrincipalCollection] 39 | not_principal: Optional[PrincipalCollection] 40 | condition: Optional[RawConditionCollection] 41 | 42 | class Config: 43 | """Configure the Pydantic BaseModel.""" 44 | 45 | alias_generator = to_pascal 46 | 47 | def policy_json(self) -> str: 48 | return self.json(by_alias=True, exclude_none=True) 49 | 50 | @property 51 | def policy_shards(self) -> List[PolicyShard]: 52 | conditions: FrozenSet[Condition] = frozenset({}) 53 | not_principals: FrozenSet[Principal] = frozenset({}) 54 | if self.condition: 55 | conditions = frozenset(self.condition.conditions) 56 | if self.not_principal: 57 | not_principals = frozenset(self.not_principal.principals) 58 | result = [] 59 | arps = [] 60 | for action in self.action or [Action("*")]: 61 | if self.principal: 62 | principals = self.principal.principals 63 | else: 64 | principals = [Principal(PrincipalType("AWS"), PrincipalValue("*"))] 65 | for principal in principals: 66 | for resource in self.resource or [Resource("*")]: 67 | arp: Tuple[Action, Resource, Principal] = (action, resource, principal) 68 | arps.append(arp) 69 | 70 | for action, resource, principal in arps: 71 | result.append( 72 | PolicyShard( 73 | effect=self.effect, 74 | effective_action=EffectiveAction( 75 | action, 76 | exclusions=frozenset(self.not_action or {}), 77 | ), 78 | effective_resource=EffectiveResource( 79 | resource, 80 | exclusions=frozenset(self.not_resource or {}), 81 | ), 82 | effective_principal=EffectivePrincipal(principal, exclusions=not_principals), 83 | effective_condition=EffectiveCondition(conditions), 84 | ) 85 | ) 86 | return result 87 | 88 | @validator("action", "not_action", pre=True) 89 | def ensure_action_list(cls, v: T) -> List[Action]: 90 | if isinstance(v, list): 91 | return [Action(action) for action in v] 92 | return [Action(v)] 93 | 94 | @validator("resource", "not_resource", pre=True) 95 | def ensure_resource_list(cls, v: T) -> List[Resource]: 96 | if isinstance(v, list): 97 | return [Resource(resource) for resource in v] 98 | return [Resource(v)] 99 | 100 | @validator("condition", pre=True) 101 | def ensure_condition_value_list( 102 | cls, v: Dict[ConditionKey, Dict[ConditionOperator, Union[ConditionValue, List[ConditionValue]]]] 103 | ) -> RawConditionCollection: 104 | output: Dict = {} 105 | for operator, key_and_values in v.items(): 106 | output[ConditionOperator(operator)] = {} 107 | for key, values in key_and_values.items(): 108 | if isinstance(values, list): 109 | output[ConditionOperator(operator)][ConditionKey(key)] = values 110 | else: 111 | output[ConditionOperator(operator)][ConditionKey(key)] = [values] 112 | return RawConditionCollection(output) 113 | 114 | @validator("principal", "not_principal", pre=True) 115 | def ensure_principal_dict( 116 | cls, v: Union[PrincipalValue, Dict[PrincipalType, Union[PrincipalValue, List[PrincipalValue]]]] 117 | ) -> PrincipalCollection: 118 | if not isinstance(v, dict): 119 | return PrincipalCollection({PrincipalType("AWS"): [PrincipalValue(v)]}) 120 | output = dict() 121 | for principal_type, principals in v.items(): 122 | if isinstance(principals, list): 123 | output[principal_type] = principals 124 | else: 125 | output[principal_type] = [principals] 126 | return PrincipalCollection(output) 127 | -------------------------------------------------------------------------------- /tests/unit/test_condition.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Condition, ConditionOperator 4 | 5 | CONDITION_REVERSIBLE_SCENARIOS = { 6 | "StringEquals": { 7 | "input": Condition("TestKey", "StringEquals", ["TestValue"]), 8 | "output": Condition("TestKey", "StringNotEquals", ["TestValue"]), 9 | }, 10 | "StringNotEquals": { 11 | "input": Condition("TestKey", "StringNotEquals", ["TestValue"]), 12 | "output": Condition("TestKey", "StringEquals", ["TestValue"]), 13 | }, 14 | "StringEqualsIgnoreCase": { 15 | "input": Condition("TestKey", "StringEqualsIgnoreCase", ["TestValue"]), 16 | "output": Condition("TestKey", "StringNotEqualsIgnoreCase", ["TestValue"]), 17 | }, 18 | "StringNotEqualsIgnoreCase": { 19 | "input": Condition("TestKey", "StringNotEqualsIgnoreCase", ["TestValue"]), 20 | "output": Condition("TestKey", "StringEqualsIgnoreCase", ["TestValue"]), 21 | }, 22 | "StringLike": { 23 | "input": Condition("TestKey", "StringLike", ["TestValue"]), 24 | "output": Condition("TestKey", "StringNotLike", ["TestValue"]), 25 | }, 26 | "StringNotLike": { 27 | "input": Condition("TestKey", "StringNotLike", ["TestValue"]), 28 | "output": Condition("TestKey", "StringLike", ["TestValue"]), 29 | }, 30 | "NumericEquals": { 31 | "input": Condition("TestKey", "NumericEquals", ["1"]), 32 | "output": Condition("TestKey", "NumericNotEquals", ["1"]), 33 | }, 34 | "NumericNotEquals": { 35 | "input": Condition("TestKey", "NumericNotEquals", ["1"]), 36 | "output": Condition("TestKey", "NumericEquals", ["1"]), 37 | }, 38 | "NumericLessThan": { 39 | "input": Condition("TestKey", "NumericLessThan", ["1"]), 40 | "output": Condition("TestKey", "NumericGreaterThanEquals", ["1"]), 41 | }, 42 | "NumericGreaterThan": { 43 | "input": Condition("TestKey", "NumericGreaterThan", ["1"]), 44 | "output": Condition("TestKey", "NumericLessThanEquals", ["1"]), 45 | }, 46 | "NumericLessThanEquals": { 47 | "input": Condition("TestKey", "NumericLessThanEquals", ["1"]), 48 | "output": Condition("TestKey", "NumericGreaterThan", ["1"]), 49 | }, 50 | "NumericGreaterThanEquals": { 51 | "input": Condition("TestKey", "NumericGreaterThanEquals", ["1"]), 52 | "output": Condition("TestKey", "NumericLessThan", ["1"]), 53 | }, 54 | "DateEquals": { 55 | "input": Condition("TestKey", "DateEquals", ["2020-01-01T00:00:01Z"]), 56 | "output": Condition("TestKey", "DateNotEquals", ["2020-01-01T00:00:01Z"]), 57 | }, 58 | "DateNotEquals": { 59 | "input": Condition("TestKey", "DateNotEquals", ["2020-01-01T00:00:01Z"]), 60 | "output": Condition("TestKey", "DateEquals", ["2020-01-01T00:00:01Z"]), 61 | }, 62 | "DateLessThan": { 63 | "input": Condition("TestKey", "DateLessThan", ["2020-01-01T00:00:01Z"]), 64 | "output": Condition("TestKey", "DateGreaterThanEquals", ["2020-01-01T00:00:01Z"]), 65 | }, 66 | "DateGreaterThan": { 67 | "input": Condition("TestKey", "DateGreaterThan", ["2020-01-01T00:00:01Z"]), 68 | "output": Condition("TestKey", "DateLessThanEquals", ["2020-01-01T00:00:01Z"]), 69 | }, 70 | "DateLessThanEquals": { 71 | "input": Condition("TestKey", "DateLessThanEquals", ["2020-01-01T00:00:01Z"]), 72 | "output": Condition("TestKey", "DateGreaterThan", ["2020-01-01T00:00:01Z"]), 73 | }, 74 | "DateGreaterThanEquals": { 75 | "input": Condition("TestKey", "DateGreaterThanEquals", ["2020-01-01T00:00:01Z"]), 76 | "output": Condition("TestKey", "DateLessThan", ["2020-01-01T00:00:01Z"]), 77 | }, 78 | "IpAddress": { 79 | "input": Condition("TestKey", "IpAddress", ["203.0.113.0/24"]), 80 | "output": Condition("TestKey", "NotIpAddress", ["203.0.113.0/24"]), 81 | }, 82 | "NotIpAddress": { 83 | "input": Condition("TestKey", "NotIpAddress", ["203.0.113.0/24"]), 84 | "output": Condition("TestKey", "IpAddress", ["203.0.113.0/24"]), 85 | }, 86 | "ArnEquals": { 87 | "input": Condition("TestKey", "ArnEquals", ["203.0.113.0/24"]), 88 | "output": Condition("TestKey", "ArnNotEquals", ["203.0.113.0/24"]), 89 | }, 90 | "ArnNotEquals": { 91 | "input": Condition("TestKey", "ArnNotEquals", ["203.0.113.0/24"]), 92 | "output": Condition("TestKey", "ArnEquals", ["203.0.113.0/24"]), 93 | }, 94 | } 95 | 96 | 97 | @pytest.mark.parametrize("_, scenario", CONDITION_REVERSIBLE_SCENARIOS.items()) 98 | def test_condition_reversible(_, scenario): 99 | input = scenario["input"] 100 | output = scenario["output"] 101 | 102 | assert input.reverse == output 103 | 104 | 105 | @pytest.mark.parametrize("_, scenario", CONDITION_REVERSIBLE_SCENARIOS.items()) 106 | def test_condition_reversible_if_exists(_, scenario): 107 | input = scenario["input"] 108 | input.operator = ConditionOperator(input.operator + "IfExists") 109 | output = scenario["output"] 110 | output.operator = ConditionOperator(output.operator + "IfExists") 111 | 112 | assert input.reverse == output 113 | 114 | 115 | CONDITION_NON_REVERSIBLE_SCENARIOS = { 116 | "BinaryEquals": { 117 | "input": Condition("TestKey", "BinaryEquals", ["TestValue"]), 118 | } 119 | } 120 | 121 | 122 | @pytest.mark.parametrize("_, scenario", CONDITION_NON_REVERSIBLE_SCENARIOS.items()) 123 | def test_condition_not_reversible(_, scenario): 124 | input = scenario["input"] 125 | 126 | with pytest.raises(ValueError) as ex: 127 | input.reverse 128 | assert f"Cannot reverse conditions with operator {input.operator}" in str(ex.value) 129 | 130 | 131 | def test_condition_reversal_not_case_sensitive(): 132 | assert Condition("TestKey", "STRINGEQUALS", ["TestValue"]).reverse == Condition( 133 | "TestKey", "stringnotequals", ["TestValue"] 134 | ) 135 | -------------------------------------------------------------------------------- /tests/unit/intersection/test_effective_condition.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, Condition, EffectiveCondition 4 | 5 | 6 | def test_bad_intersection(): 7 | with pytest.raises(ValueError) as ex: 8 | EffectiveCondition( 9 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 10 | ).intersection(Action("S3:*")) 11 | 12 | assert "Cannot intersect EffectiveCondition with Action" in str(ex.value) 13 | 14 | 15 | INTERSECTION_SCENARIOS = { 16 | "proper_subset": { 17 | "first": EffectiveCondition( 18 | frozenset( 19 | { 20 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 21 | Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 22 | } 23 | ), 24 | frozenset(), 25 | ), 26 | "second": EffectiveCondition( 27 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 28 | ), 29 | "result": EffectiveCondition( 30 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 31 | ), 32 | }, 33 | "proper_subset_with_exclusions": { 34 | "first": EffectiveCondition( 35 | frozenset( 36 | { 37 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 38 | Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 39 | } 40 | ), 41 | frozenset(), 42 | ), 43 | "second": EffectiveCondition( 44 | frozenset( 45 | { 46 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 47 | } 48 | ), 49 | frozenset({Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 50 | ), 51 | "result": EffectiveCondition( 52 | frozenset( 53 | { 54 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 55 | } 56 | ), 57 | frozenset(), 58 | ), 59 | }, 60 | # This is commented out until we deal with the fact that some conditions can negate each other, as the exclusions 61 | # of first set won't negate second, but a condition in first that negates a condition in second will. 62 | # "excluded_proper_subset": { 63 | # "first": EffectiveCondition( 64 | # frozenset( 65 | # { 66 | # Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 67 | # Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 68 | # } 69 | # ), 70 | # frozenset({Condition(key="key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])}), 71 | # ), 72 | # "second": EffectiveCondition( 73 | # frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 74 | # ), 75 | # "result": None, 76 | # }, 77 | "subset": { 78 | "first": EffectiveCondition( 79 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 80 | ), 81 | "second": EffectiveCondition( 82 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 83 | ), 84 | "result": EffectiveCondition( 85 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 86 | ), 87 | }, 88 | "disjoint": { 89 | "first": EffectiveCondition( 90 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), frozenset() 91 | ), 92 | "second": EffectiveCondition( 93 | frozenset( 94 | {Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"])} 95 | ), 96 | frozenset(), 97 | ), 98 | "result": EffectiveCondition(frozenset(), frozenset()), 99 | }, 100 | "larger": { 101 | "first": EffectiveCondition( 102 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 103 | frozenset(), 104 | ), 105 | "second": EffectiveCondition( 106 | frozenset( 107 | { 108 | Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 109 | Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 110 | } 111 | ), 112 | frozenset(), 113 | ), 114 | "result": EffectiveCondition( 115 | frozenset({Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"])}), 116 | frozenset(), 117 | ), 118 | }, 119 | # "larger_with_exclusion": { 120 | # "first": EffectiveCondition(Action("S3:Get*")), 121 | # "second": EffectiveCondition( 122 | # frozenset( 123 | # { 124 | # Condition("aws:PrincipalOrgId", "StringNotEquals", ["o-123456"]), 125 | # Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"]), 126 | # } 127 | # ), 128 | # frozenset(), 129 | # ), 130 | # "result": EffectiveCondition(Action("S3:Get*"), frozenset({Action("S3:GetObject")})), 131 | # }, 132 | } 133 | 134 | 135 | @pytest.mark.parametrize("_, scenario", INTERSECTION_SCENARIOS.items()) 136 | def test_intersection(_, scenario): 137 | first, second, result = scenario.values() 138 | assert first.intersection(second) == result 139 | -------------------------------------------------------------------------------- /doc_source/class_reference/understanding_effective_actions.rst: -------------------------------------------------------------------------------- 1 | Understanding Effective Actions 2 | ================================ 3 | 4 | In PolicyGlass we express ARPs (:class:`~policyglass.action.Action` :class:`~policyglass.resource.Resource` :class:`policyglass.principal.Principal`) as though they are potentially 5 | infinite sets. 6 | 7 | In reality they are finite sets because there are only a finite number of allowed actions, resources, or principals. 8 | However because actions are being constantly updated by AWS, and new resources and princiapls are being created all 9 | the time, we here treat them as infinite sets because their extent is unknowable by us when we are parsing the policy. 10 | 11 | Components of an EffectiveAction 12 | ----------------------------------- 13 | 14 | An :class:`~policyglass.action.EffectiveAction` object has two components: 15 | 16 | #. :attr:`~policyglass.action.EffectiveAction.inclusion` 17 | #. :attr:`~policyglass.action.EffectiveAction.exclusions` 18 | 19 | The inclusions indicate the :class:`~policyglass.action.Action` that this effective action applies to 20 | and the exclusions indicate the actions that this effective action *does not* apply to. 21 | 22 | At its simplest an effective action is just an inclusion, which you can think of as a Venn diagram 23 | containing ``S3:*``. 24 | 25 | .. figure:: ../images/action_without_exclusion.png 26 | :height: 20em 27 | 28 | EffectiveAction without exclusion 29 | 30 | Then if you have an exclusion of ``S3:Get*`` you can think of this as a hole punched in the Venn diagram. 31 | 32 | .. figure:: ../images/action_with_exclusion.png 33 | :height: 20em 34 | 35 | EffectiveAction with exclusion 36 | 37 | The area in the middle indicating that ``S3:Get*`` is not included in the effective action. 38 | 39 | 40 | Difference 41 | ------------- 42 | 43 | The *difference* between set *x* and set *y* is the elements 44 | that are contained in set *x* that are not contained in set *y*. 45 | In essence it's a subtraction. Remove the elements in set *y* from set *x* and you have the difference. 46 | 47 | Simple 48 | """""""""" 49 | 50 | Let's say we calculate the difference between two effective actions like so. 51 | 52 | .. doctest :: 53 | 54 | >>> from policyglass import EffectiveAction, Action 55 | >>> x = EffectiveAction(inclusion=Action("S3:*")) 56 | >>> y = EffectiveAction(inclusion=Action("S3:Get*")) 57 | >>> x.difference(y) 58 | [EffectiveAction(inclusion=Action('S3:*'), exclusions=frozenset({Action('S3:Get*')}))] 59 | 60 | The result is that the inclusion from *y* is added to the *exclusions* of *x*. 61 | 62 | .. figure:: ../images/action_with_exclusion.png 63 | :height: 20em 64 | 65 | Simple Difference 66 | 67 | - ``S3:*`` is the ``inclusion`` from ``x`` 68 | - ``S3:Get*`` is the ``inclusion`` from ``y`` 69 | 70 | 71 | The inclusion from *x* is added as an exclusion of *y* is because our Actions are essentially infinite sets. The wildcard at the end of ``S3:*`` 72 | could extend to an infinitely long string for all we know, so we can't create an :class:`~policyglass.action.Action` that 73 | expresses `S3:*` but not `S3:Get*` so we must add it as an exclusion in an :class:`~policyglass.action.EffectiveAction`. 74 | 75 | This is the reason :class:`~policyglass.action.EffectiveAction` s exist, so we can express the 76 | intersection of the complement of ininite set B with inifite set A. 77 | 78 | 79 | Complex 80 | """""""""""" 81 | 82 | Let's say we have two effective actions we want to diff. 83 | One is just ``S3:*`` and the other is ``S3:Get*`` except for ``S3:GetObject``. 84 | To diff these we want to subtract ``S3:Get*`` from ``S3:*`` but leave ``S3:GetObject`` in place. 85 | 86 | .. doctest :: 87 | 88 | >>> from policyglass import EffectiveAction, Action 89 | >>> x = EffectiveAction(inclusion=Action("S3:*")) 90 | >>> y = EffectiveAction(inclusion=Action("S3:Get*"), exclusions=frozenset({Action("S3:GetObject")})) 91 | >>> print(x.difference(y)) 92 | [EffectiveAction(inclusion=Action('S3:*'), exclusions=frozenset({Action('S3:Get*')})), 93 | EffectiveAction(inclusion=Action('S3:GetObject'), exclusions=frozenset())] 94 | 95 | Let's unpack what happened here. 96 | 97 | 1. We added the *inclusion* (``S3:get*``) from *y* to the exclusions of *x* 98 | 2. We returned a new effective action that is just ``S3:GetObject`` 99 | 100 | .. figure:: ../images/complex_difference.png 101 | :height: 20em 102 | 103 | Complex Difference (theoretical) 104 | 105 | 106 | - ``S3:*`` is our ``inclusion`` from ``x`` 107 | - ``S3:Get*`` is our ``inclusion`` from ``y`` 108 | - ``S3:GetObject`` is our ``exclusion`` from ``y`` 109 | 110 | In the above Venn diagram we're showing that the difference between the two effective actions is 111 | to include ``S3:*`` except ``S3:Get*`` but still include ``S3:GetObject``. 112 | We can't have an inclusion inside an exclusion so we represent this by adding another effective action object 113 | to represent the inclusion. 114 | 115 | .. figure:: ../images/complex_difference_output.png 116 | :height: 20em 117 | 118 | Complex Difference (actual output) 119 | 120 | Outputting two effective actions makes a list of :class:`~policyglass.policy_shard.PolicyShard` objects 121 | much easier to understand because you will end up with two shards (one for each effective action) rather 122 | than one super hard to understand shard that has an action inclusion inside an action exclusion inside an 123 | action inclusion. 124 | 125 | Remember that the exclusions in an EffectiveAction are negations, they are holes punched in what's allowed. 126 | As a result, what is in the exclusion of *y* should **not** be removed from *x* because it's explicitly not part of *y*. 127 | 128 | Because we can't express the fact that we want to exclude B and C but **include** A in our result, we have to return 129 | two separate :class:`~policyglass.action.EffectiveAction` s, one which includes A but the entirety of B, and another that just includes D. 130 | -------------------------------------------------------------------------------- /doc_source/examples_policy_analysis.rst: -------------------------------------------------------------------------------- 1 | Examples of Policy Analysis 2 | ============================= 3 | 4 | Example Policy 5 | --------------------- 6 | 7 | Let's use a complex IAM policy as our example to demonstrate the value in analyzing policies with PolicyGlass. 8 | 9 | .. doctest:: 10 | 11 | >>> from policyglass import Policy 12 | >>> test_policy = Policy(**{ 13 | ... "Version": "2012-10-17", 14 | ... "Statement": [ 15 | ... { 16 | ... "Effect": "Allow", 17 | ... "Action": [ 18 | ... "s3:*" 19 | ... ], 20 | ... "Resource": "*", 21 | ... "Condition": { 22 | ... "NumericLessThan": { 23 | ... "s3:TlsVersion": 1.2 24 | ... } 25 | ... } 26 | ... }, 27 | ... { 28 | ... "Effect": "Allow", 29 | ... "Action": [ 30 | ... "s3:*" 31 | ... ], 32 | ... "Resource": "arn:aws:s3:::examplebucket/*" 33 | ... }, 34 | ... { 35 | ... "Effect": "Deny", 36 | ... "Action": [ 37 | ... "s3:PutObject" 38 | ... ], 39 | ... "NotResource": "arn:aws:s3:::examplebucket/*", 40 | ... "Condition": { 41 | ... "StringNotEquals": { 42 | ... "s3:x-amz-server-side-encryption": "AES256" 43 | ... } 44 | ... } 45 | ... } 46 | ... ] 47 | ... }) 48 | 49 | Understanding a Policy 50 | """""""""""""""""""""""""" 51 | 52 | To understand the policy, let's get the :meth:`~policyglass.policy_shard.policy_shards_effect` then use the :meth:`~policyglass.policy_shard.explain_policy_shards` method to explain them. 53 | 54 | .. doctest:: 55 | 56 | >>> from policyglass import policy_shards_effect, explain_policy_shards 57 | >>> test_policy_shards = policy_shards_effect(test_policy.policy_shards) 58 | >>> explain_policy_shards(test_policy_shards) 59 | ["Allow action s3:PutObject on resource * (except for arn:aws:s3:::examplebucket/*) with principal AWS *. 60 | Provided conditions s3:TlsVersion NumericLessThan ['1.2'] and s3:x-amz-server-side-encryption StringEquals ['AES256'] are met.", 61 | "Allow action s3:* (except for s3:PutObject) on resource * (except for arn:aws:s3:::examplebucket/*) with principal AWS *. 62 | Provided conditions s3:TlsVersion NumericLessThan ['1.2'] are met.", 63 | 'Allow action s3:* on resource arn:aws:s3:::examplebucket/* with principal AWS *.'] 64 | 65 | 66 | That helps clarify what the policy results in for humans. But what if we want to programatically ask a question about what this allows? 67 | 68 | Interrogating a Policy 69 | """""""""""""""""""""""""""" 70 | 71 | Question: 72 | Is ``s3:PutObject`` allowed on ``arn:aws:s3:::some-other-bucket/*``? 73 | 74 | To answer this we need to check 2 things: 75 | 76 | #. Is ``s3:PutObject`` allowed on the shard? 77 | #. If so, is ``resource arn:aws:s3:::examplebucket/*`` allowed on the same shard? 78 | 79 | As we have multiple (3) shards we have to make sure both of the answers are true for the same shard. 80 | 81 | We can do this with a list comprehension and utilise the ``in`` operator to check that the 82 | :class:`~policyglass.action.EffectiveAction` contains ``s3:PutObject`` and that the 83 | :class:`~policyglass.resource.EffectiveResource` contains ``arn:aws:s3:::some-other-bucket/*``. 84 | 85 | .. doctest:: 86 | 87 | >>> from policyglass import Action, Resource 88 | >>> action = Action('s3:PutObject') 89 | >>> resource = Resource('arn:aws:s3:::some-other-bucket/*') 90 | >>> result = [ 91 | ... shard 92 | ... for shard in test_policy_shards 93 | ... if action in shard.effective_action 94 | ... and resource in shard.effective_resource 95 | ... ] 96 | >>> result # doctest: +SKIP 97 | [PolicyShard(effect='Allow', 98 | effective_action=EffectiveAction(inclusion=Action('s3:PutObject'), exclusions=frozenset()), 99 | effective_resource=EffectiveResource(inclusion=Resource('*'), exclusions=frozenset({Resource('arn:aws:s3:::examplebucket/*')})), 100 | effective_principal=EffectivePrincipal(inclusion=Principal(type='AWS', value='*'), exclusions=frozenset()), 101 | effective_condition=EffectiveCondition(inclusions=frozenset({Condition(key='s3:x-amz-server-side-encryption', operator='StringEquals', values=['AES256']), 102 | Condition(key='s3:TlsVersion', operator='NumericLessThan', values=['1.2'])}), 103 | exclusions=frozenset()))] 104 | 105 | .. doctest:: 106 | :hide: 107 | 108 | >>> from policyglass import ( 109 | ... Principal, 110 | ... PolicyShard, 111 | ... EffectiveAction, 112 | ... EffectiveResource, 113 | ... EffectivePrincipal, 114 | ... EffectiveCondition, 115 | ... Condition 116 | ... ) 117 | >>> assert result == [PolicyShard(effect='Allow', 118 | ... effective_action=EffectiveAction(inclusion=Action('s3:PutObject'), exclusions=frozenset()), 119 | ... effective_resource=EffectiveResource(inclusion=Resource('*'), exclusions=frozenset({Resource('arn:aws:s3:::examplebucket/*')})), 120 | ... effective_principal=EffectivePrincipal(inclusion=Principal(type='AWS', value='*'), exclusions=frozenset()), 121 | ... effective_condition=EffectiveCondition(inclusions=frozenset({Condition(key='s3:x-amz-server-side-encryption', operator='StringEquals', values=['AES256']), 122 | ... Condition(key='s3:TlsVersion', operator='NumericLessThan', values=['1.2'])}), 123 | ... exclusions=frozenset()))] 124 | 125 | From this check we can see that it is allowed by at least one shard! **But** there are two conditions. 126 | 127 | Checking if Conditions exist 128 | """""""""""""""""""""""""""""""" 129 | Whether we want to check these conditions depends on what kind of question we want to ask. 130 | Either way it's trivial to check if a condition exists or not. 131 | 132 | .. doctest:: 133 | 134 | >>> bool(result[0].effective_condition) 135 | True 136 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.8.0 2 | 3 | - `dedupe_policy_shard_subsets` now sorts input by `effective_resource` improving readability in scenarios with simple denies that deny both Action and Resource. 4 | - Added `union` to `EffectiveCondition` 5 | - Added `reverse` to `EffectiveCondition` 6 | - Fixed bug causing denies with multiple ARPs to deny more than they should because `PolicyShard.difference` didn't generate shards that accounted for one of the ARPs not being met. 7 | - Split out `_decompose_difference` into `_decompose_difference_arps_with_combined_conditions` and `_decompose_difference_arps_with_self_conditions` 8 | 9 | # 0.7.0 10 | 11 | - Added `__bool__` and `intersection` to `EffectiveCondition` 12 | - Replaced `conditions` and `not_conditions` on `PolicyShard` with `effective_condition` 13 | - Fixed bug with `PolicyShard.union` where it lost `not_conditions` 14 | 15 | # 0.6.0 16 | 17 | - Renamed `delineate_intersecting_shards` to `dedupe_policy_shards` to better reflect how people will use it. 18 | - Added `explain_policy_shards` to eventually replace `explain` attribute on `PolicyShard` class entirely. 19 | - Added `__contains__` to `EffectiveARP` classes 20 | - Added `reverse` method to `Condition` to reverse the operator/value to reverse the effect of the condition. 21 | - Added `EffectiveCondition` class to house `factory` method which normalisises `not_conditions` into `conditions` where possible. This may end up being a replacement for the `conditions` and `not_conditions` attributes on `PolicyShard`. 22 | - Normlised `not_conditions` into `conditions` where possible upon instantiation of `PolicyShard`. 23 | - `policy_shards_effect` now runs `dedupe_policy_shards` at the end to simplify the most common execution paths. 24 | 25 | # 0.5.0 26 | 27 | - Renamed `dedupe_policy_shards` to `dedupe_policy_shard_subsets` to differentiate it from `delineate_intersecting_shards`. 28 | - Added `delineate_intersecting_shards` to reduce the size of `PolicyShard`s which have conditions whose ARPs intersect with ones without conditions. 29 | This helps clear up [#10](https://github.com/CloudWanderer-io/PolicyGlass/issues/10) 30 | - Improved `issubset` on `PolicyShard` to recognise that a shard without conditions CANNOT be a subset of a shard with conditions. 31 | - Added `<` and `>` to `PolicyShard`. 32 | - Updated `difference` on `PolicyShard` so that it only adds `other`'s conditions to `self`'s not_conditions if self is allow and other is deny. 33 | - Added documentation on how PolicyShard dedupe works 34 | - Renamed `ConditionCollection` to `RawConditionCollection` 35 | - Ensured that Conditions are always treated as a set not a list. 36 | - Ensured that Condition's Operator, Key, Values are always of type ConditionOperator, ConditionKey and ConditionValue. 37 | - Corrected bug in `CaseInsensitiveString` that caused it to generate case sensitive hashes. 38 | - Added `dedupe_result` param to `difference` method on `PolicyShard` to allow merging of intersecting shards that are not subsets of one another. 39 | - Added `intersection` to `PolicyShard`. 40 | - Prevent attempting to calculate the difference between a Deny shard and an Allow shard. Other way makes sense as that's effective permission. 41 | - Updated PolicyShard implementation to Support pydantic 1.9 42 | 43 | # 0.4.7 44 | 45 | - Fixed case insensitive `fnmatch` for resources 46 | - Made it impossible to instantiate EffectiveARPs that have exclusions that are not proper subsets of their inclusions 47 | - Ensured EffectiveARP's `intersection` filters out conditions from `other` that don't overlap `self` and vice versa when assembling new ARPs 48 | - Added `Factory` method on EffectiveARP to faciltate creation of objects whose inclusions may overlap their inclusions (i.e. by returning `None`) 49 | - Added `__lt__` and `__gt__` to EffectiveARP to represent proper subsets 50 | - 51 | 52 | # 0.4.6 53 | 54 | - Reverse order for second pass of dedupe to prevent failing to merge things due to sort order. 55 | # 0.4.5 56 | 57 | - Fixed PolicyShards with _additional_ conditions not being marked as subsets of shards that had a subset of those conditions 58 | - Ensured that conditions are taken into effect properly for PolicyShard's `issubset` 59 | 60 | # 0.4.4 61 | 62 | - Fixed equal `EffectiveARPs` not being considerered subsets of each toher. 63 | - Fixed PolicyShard's `difference` to consider `conditions` and `not_conditions` in whether the shards overlap wholly. 64 | - Updated PolicyShard's `difference` to only add a `difference` shard if there _is_ in fact a difference. 65 | - Updated PolicyShard's `difference` to add a `PolicyShard` that is identical to self with other's condition as a not_condition if the conditions are not equal. 66 | - Updated Policyshard's `difference` to create every possible combination of `difference_`, `self.effective_` and `intersection_` and then dedupe the results to accurately compute the difference. (The test result of `difference/test_policyshard.py::deny_action_and_resource_subsets` is not how I would express it but is accurate.) 67 | - Fixed PolicyShard equality not considering not_conditions 68 | - EffectiveARP - If any of other's exclusions excludes something self DOESN'T then self is not a subset of other. 69 | - EffectiveARP - Fixed `issubset` bug when self was excluded by other. 70 | - Consider a `PolicyShard` that has a condition a subset of a `PolicyShard` that doesn't have a condition if all other signs point to it being a subset. 71 | 72 | # 0.4.3 73 | 74 | - Fixed bug causing `EffectiveARP` exclusions not to be honoured by `difference` methods. 75 | 76 | # 0.4.2 77 | 78 | - Fixed bug causing Condition Keys and Operators to be swapped. 79 | 80 | # 0.4.1 81 | 82 | - Improved formatting of `PolicyShard` explain. 83 | 84 | # 0.4.0 85 | 86 | - Added `PolicyShard` explain. 87 | 88 | # 0.3.0 89 | 90 | - Updated examples to be easier to read. 91 | - Added `policy_shards_to_json`. 92 | - Added `exclude_defults` to `EffectiveARP.dict()`. 93 | 94 | # 0.2.1 95 | 96 | - Added `not_conditions` into the repr for `PolicyShard` 97 | - Added `not_conditions` into the simple diff scenario 98 | - Added `conditions` into the complex diff scenario 99 | - Added `not_action`, `not_resource`, and `not_principal` into `policy_shards` returned from `Statement`. 100 | - Added `examples.rst` 101 | 102 | # 0.2.0 103 | 104 | - First functional candidate 105 | 106 | # 0.1.0 107 | 108 | - PyPi name placeholder. 109 | -------------------------------------------------------------------------------- /tests/unit/test_policy.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import ( 4 | Action, 5 | Condition, 6 | EffectiveAction, 7 | EffectivePrincipal, 8 | EffectiveResource, 9 | Policy, 10 | PolicyShard, 11 | Principal, 12 | Resource, 13 | ) 14 | from policyglass.condition import EffectiveCondition 15 | 16 | POLICIES = { 17 | "simple_iam_policy": { 18 | "policy": { 19 | "Version": "2012-10-17", 20 | "Statement": [ 21 | { 22 | "Effect": "Allow", 23 | "Action": ["ec2:AttachVolume"], 24 | "Resource": ["arn:aws:ec2:*:*:volume/*"], 25 | } 26 | ], 27 | }, 28 | "shards": [ 29 | PolicyShard( 30 | effect="Allow", 31 | effective_action=EffectiveAction(inclusion=Action("ec2:AttachVolume"), exclusions=frozenset()), 32 | effective_resource=EffectiveResource( 33 | inclusion=Resource("arn:aws:ec2:*:*:volume/*"), exclusions=frozenset() 34 | ), 35 | effective_principal=EffectivePrincipal( 36 | inclusion=Principal(type="AWS", value="*"), exclusions=frozenset() 37 | ), 38 | ) 39 | ], 40 | }, 41 | "complex_iam_policy": { 42 | "policy": { 43 | "Version": "2012-10-17", 44 | "Statement": [ 45 | { 46 | "Effect": "Allow", 47 | "Action": ["ec2:AttachVolume"], 48 | "Resource": ["arn:aws:ec2:*:*:volume/*"], 49 | "Condition": {"ArnEquals": {"ec2:SourceInstanceARN": ["arn:aws:ec2:*:*:instance/instance-id"]}}, 50 | } 51 | ], 52 | }, 53 | "shards": [ 54 | PolicyShard( 55 | effect="Allow", 56 | effective_action=EffectiveAction(inclusion=Action("ec2:AttachVolume"), exclusions=frozenset()), 57 | effective_resource=EffectiveResource( 58 | inclusion=Resource("arn:aws:ec2:*:*:volume/*"), exclusions=frozenset() 59 | ), 60 | effective_principal=EffectivePrincipal( 61 | inclusion=Principal(type="AWS", value="*"), exclusions=frozenset() 62 | ), 63 | effective_condition=EffectiveCondition( 64 | frozenset( 65 | { 66 | Condition( 67 | key="ec2:SourceInstanceARN", 68 | operator="ArnEquals", 69 | values=["arn:aws:ec2:*:*:instance/instance-id"], 70 | ) 71 | } 72 | ) 73 | ), 74 | ) 75 | ], 76 | }, 77 | "complex_resource_policy": { 78 | "policy": { 79 | "Version": "2012-10-17", 80 | "Statement": [ 81 | { 82 | "Effect": "Allow", 83 | "Action": ["s3:PutObject", "s3:PutObjectAcl"], 84 | "Resource": ["arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"], 85 | "Principal": {"AWS": ["arn:aws:iam::111122223333:root", "arn:aws:iam::444455556666:root"]}, 86 | "Condition": {"StringEquals": {"s3:x-amz-acl": ["public-read"]}}, 87 | } 88 | ], 89 | }, 90 | "shards": [ 91 | PolicyShard( 92 | effect="Allow", 93 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 94 | effective_resource=EffectiveResource( 95 | inclusion=Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"), exclusions=frozenset() 96 | ), 97 | effective_principal=EffectivePrincipal( 98 | inclusion=Principal(type="AWS", value="arn:aws:iam::111122223333:root"), exclusions=frozenset() 99 | ), 100 | effective_condition=EffectiveCondition( 101 | frozenset({Condition(key="s3:x-amz-acl", operator="StringEquals", values=["public-read"])}) 102 | ), 103 | ), 104 | PolicyShard( 105 | effect="Allow", 106 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 107 | effective_resource=EffectiveResource( 108 | inclusion=Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"), exclusions=frozenset() 109 | ), 110 | effective_principal=EffectivePrincipal( 111 | inclusion=Principal(type="AWS", value="arn:aws:iam::444455556666:root"), exclusions=frozenset() 112 | ), 113 | effective_condition=EffectiveCondition( 114 | frozenset({Condition(key="s3:x-amz-acl", operator="StringEquals", values=["public-read"])}) 115 | ), 116 | ), 117 | PolicyShard( 118 | effect="Allow", 119 | effective_action=EffectiveAction(inclusion=Action("s3:PutObjectAcl"), exclusions=frozenset()), 120 | effective_resource=EffectiveResource( 121 | inclusion=Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"), exclusions=frozenset() 122 | ), 123 | effective_principal=EffectivePrincipal( 124 | inclusion=Principal(type="AWS", value="arn:aws:iam::111122223333:root"), exclusions=frozenset() 125 | ), 126 | effective_condition=EffectiveCondition( 127 | frozenset({Condition(key="s3:x-amz-acl", operator="StringEquals", values=["public-read"])}) 128 | ), 129 | ), 130 | PolicyShard( 131 | effect="Allow", 132 | effective_action=EffectiveAction(inclusion=Action("s3:PutObjectAcl"), exclusions=frozenset()), 133 | effective_resource=EffectiveResource( 134 | inclusion=Resource("arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"), exclusions=frozenset() 135 | ), 136 | effective_principal=EffectivePrincipal( 137 | inclusion=Principal(type="AWS", value="arn:aws:iam::444455556666:root"), exclusions=frozenset() 138 | ), 139 | effective_condition=EffectiveCondition( 140 | frozenset({Condition(key="s3:x-amz-acl", operator="StringEquals", values=["public-read"])}) 141 | ), 142 | ), 143 | ], 144 | }, 145 | } 146 | 147 | 148 | @pytest.mark.parametrize( 149 | "_, policy, shards", [(name, value["policy"], value["shards"]) for name, value in POLICIES.items()] 150 | ) 151 | def test_policy_shards(_, policy, shards): 152 | assert Policy(**policy).policy_shards == shards 153 | -------------------------------------------------------------------------------- /doc_source/examples_policy_shards.rst: -------------------------------------------------------------------------------- 1 | Examples of PolicyShards 2 | ========================== 3 | 4 | Below you can find some examples on how PolicyGlass can be used to understand complex policies in a consistent way. 5 | 6 | We're going to use :meth:`~policyglass.policy_shard.policy_shards_to_json` to make the output a bit easier to read. 7 | 8 | .. tip:: 9 | 10 | Remember :class:`~policyglass.policy_shard.PolicyShard` objects are *not* policies. 11 | They represent policies in an abstracted way that makes them easier to understand programmatically, the JSON output 12 | you see in the examples is not a policy you can use directly in AWS. 13 | 14 | Simple 15 | ----------- 16 | 17 | .. doctest:: 18 | 19 | >>> from policyglass import Policy, dedupe_policy_shards, policy_shards_effect, policy_shards_to_json 20 | >>> policy_a = Policy(**{ 21 | ... "Version": "2012-10-17", 22 | ... "Statement": [ 23 | ... { 24 | ... "Effect": "Allow", 25 | ... "Action": [ 26 | ... "s3:*" 27 | ... ], 28 | ... "Resource": "*" 29 | ... } 30 | ... ] 31 | ... }) 32 | >>> policy_b = Policy(**{ 33 | ... "Version": "2012-10-17", 34 | ... "Statement": [ 35 | ... { 36 | ... "Effect": "Deny", 37 | ... "Action": [ 38 | ... "s3:*" 39 | ... ], 40 | ... "Resource": "arn:aws:s3:::examplebucket/*" 41 | ... } 42 | ... ] 43 | ... }) 44 | >>> policy_shards = policy_shards_effect([*policy_a.policy_shards, *policy_b.policy_shards]) 45 | >>> print(policy_shards_to_json(policy_shards, exclude_defaults=True, indent=2)) 46 | [ 47 | { 48 | "effective_action": { 49 | "inclusion": "s3:*" 50 | }, 51 | "effective_resource": { 52 | "inclusion": "*", 53 | "exclusions": [ 54 | "arn:aws:s3:::examplebucket/*" 55 | ] 56 | }, 57 | "effective_principal": { 58 | "inclusion": { 59 | "type": "AWS", 60 | "value": "*" 61 | } 62 | } 63 | } 64 | ] 65 | 66 | PolicyShard #1 (first dictonary in list) tells us: 67 | #. `s3:*` is allowed for all resources **except** ``arn:aws:s3:::examplebucket/*`` 68 | 69 | What occurred: 70 | #. The ``resource`` from the deny was added to the allow's ``EffectiveResource``'s ``exclusions`` 71 | 72 | De-duplicate 73 | ------------- 74 | 75 | .. doctest:: 76 | 77 | >>> from policyglass import Policy, dedupe_policy_shards, policy_shards_to_json 78 | >>> policy_a = Policy(**{ 79 | ... "Version": "2012-10-17", 80 | ... "Statement": [ 81 | ... { 82 | ... "Effect": "Allow", 83 | ... "Action": [ 84 | ... "s3:*" 85 | ... ], 86 | ... "Resource": "*" 87 | ... } 88 | ... ] 89 | ... }) 90 | >>> policy_b = Policy(**{ 91 | ... "Version": "2012-10-17", 92 | ... "Statement": [ 93 | ... { 94 | ... "Effect": "Allow", 95 | ... "Action": [ 96 | ... "s3:*" 97 | ... ], 98 | ... "Resource": "*" 99 | ... } 100 | ... ] 101 | ... }) 102 | >>> policy_shards = dedupe_policy_shards([*policy_a.policy_shards, *policy_b.policy_shards]) 103 | >>> print(policy_shards_to_json(policy_shards, exclude_defaults=True, indent=2)) 104 | [ 105 | { 106 | "effective_action": { 107 | "inclusion": "s3:*" 108 | }, 109 | "effective_resource": { 110 | "inclusion": "*" 111 | }, 112 | "effective_principal": { 113 | "inclusion": { 114 | "type": "AWS", 115 | "value": "*" 116 | } 117 | } 118 | } 119 | ] 120 | 121 | PolicyShard #1 (first dictonary in list) tells us: 122 | #. ``s3:*`` is allowed on all resources. 123 | 124 | What occurred: 125 | #. One of the two ``s3:*`` policy shards was removed because it was a duplicate. 126 | 127 | Deny Not Resource Policy 128 | -------------------------- 129 | .. doctest:: 130 | 131 | >>> from policyglass import Policy, policy_shards_effect, policy_shards_to_json 132 | >>> policy_a = Policy(**{ 133 | ... "Version": "2012-10-17", 134 | ... "Statement": [ 135 | ... { 136 | ... "Effect": "Allow", 137 | ... "Action": [ 138 | ... "s3:*", 139 | ... "s3:GetObject" 140 | ... ], 141 | ... "Resource": "*" 142 | ... }, 143 | ... { 144 | ... "Effect": "Deny", 145 | ... "Action": [ 146 | ... "s3:*", 147 | ... ], 148 | ... "NotResource": "arn:aws:s3:::examplebucket/*", 149 | ... "Condition": { 150 | ... "StringNotEquals": { 151 | ... "s3:x-amz-server-side-encryption": "AES256" 152 | ... } 153 | ... } 154 | ... } 155 | ... ] 156 | ... }) 157 | >>> shards_effect = policy_shards_effect(policy_a.policy_shards) 158 | >>> print(policy_shards_to_json(shards_effect, exclude_defaults=True, indent=2)) 159 | [ 160 | { 161 | "effective_action": { 162 | "inclusion": "s3:*" 163 | }, 164 | "effective_resource": { 165 | "inclusion": "arn:aws:s3:::examplebucket/*" 166 | }, 167 | "effective_principal": { 168 | "inclusion": { 169 | "type": "AWS", 170 | "value": "*" 171 | } 172 | } 173 | }, 174 | { 175 | "effective_action": { 176 | "inclusion": "s3:*" 177 | }, 178 | "effective_resource": { 179 | "inclusion": "*", 180 | "exclusions": [ 181 | "arn:aws:s3:::examplebucket/*" 182 | ] 183 | }, 184 | "effective_principal": { 185 | "inclusion": { 186 | "type": "AWS", 187 | "value": "*" 188 | } 189 | }, 190 | "effective_condition": { 191 | "inclusions": [ 192 | { 193 | "key": "s3:x-amz-server-side-encryption", 194 | "operator": "StringEquals", 195 | "values": [ 196 | "AES256" 197 | ] 198 | } 199 | ] 200 | } 201 | } 202 | ] 203 | 204 | The output has two policy shards. 205 | 206 | PolicyShard #1 (first dictionary in list) tells us: 207 | #. Allow ``s3:*`` 208 | #. On ``arn:aws:s3:::examplebucket/*`` 209 | #. No conditions 210 | 211 | PolicyShard #2 (second dictionary in list) tells us: 212 | #. Allow ``s3:*`` 213 | #. On all resources 214 | #. If the condition applies. 215 | 216 | What occurred: 217 | #. ``s3:GetObject`` was removed from the allow because it was totally within ``s3:*`` 218 | #. A new ``PolicyShard`` was created with ``s3:*`` 219 | #. The deny's ``condition`` got reversed from ``StringNotEquals`` to ``StringEquals`` and added to the new allow ``PolicyShard``. 220 | -------------------------------------------------------------------------------- /policyglass/principal.py: -------------------------------------------------------------------------------- 1 | """Principal classes.""" 2 | import json 3 | import re 4 | from typing import Dict, List, Optional 5 | 6 | from pydantic import BaseModel 7 | 8 | from .effective_arp import EffectiveARP 9 | 10 | 11 | class PrincipalType(str): 12 | """A principal type, e.g. Federated or AWS. 13 | 14 | See `AWS JSON policy elements: Principal 15 | `__ 16 | for more. 17 | """ 18 | 19 | 20 | class PrincipalValue(str): 21 | """An ARN, wildcard, or other appropriate value of a policy Principal. 22 | 23 | See `AWS JSON policy elements: Principal 24 | `__ 25 | for more. 26 | """ 27 | 28 | 29 | class PrincipalCollection(Dict[PrincipalType, List[PrincipalValue]]): 30 | """A collection of Principals of different types, unique to PolicyGlass.""" 31 | 32 | @property 33 | def principals(self) -> List["Principal"]: 34 | result = [] 35 | for principal_type, principal_values in self.items(): 36 | for principal_value in principal_values: 37 | result.append(Principal(principal_type, principal_value)) 38 | return result 39 | 40 | def __hash__(self) -> int: # type: ignore[override] 41 | """Generate a hash for this principal.""" 42 | return hash(json.dumps({"candidate": 5, "data": 1}, sort_keys=True)) 43 | 44 | def __lt__(self, other: object) -> bool: 45 | """There are few scenarios in which a Principal can be said to contain another object. 46 | 47 | "You cannot use a wildcard to match part of a principal name or ARN." 48 | `AWS JSON policy elements: Principal 49 | `__ 50 | 51 | You can however use *just* a wildcard to match a whole principal. 52 | An ``arn:aws:iam::123456789012:root`` ARN also matches every principal in that account. 53 | 54 | Parameters: 55 | other: The object to see if this principal contains. 56 | """ 57 | return False 58 | 59 | def __contains__(self, other: object) -> bool: 60 | """Not Implemented. 61 | 62 | Parameters: 63 | other: The object to see if this object contains. 64 | 65 | Raises: 66 | NotImplementedError: This method is not implemented. 67 | """ 68 | raise NotImplementedError() 69 | 70 | 71 | class Principal(BaseModel): 72 | """A class which represents a single Principal including its type. 73 | 74 | Objects of this type are typically generated by the :class:`~policyglass.statement.Statement` class. 75 | """ 76 | 77 | #: Principal Type 78 | type: PrincipalType 79 | #: Principal value 80 | value: PrincipalValue 81 | 82 | def __init__(self, type: PrincipalType, value: PrincipalValue) -> None: 83 | super().__init__( 84 | type=type, 85 | value=self._normalize_account_id(value), 86 | ) 87 | 88 | def issubset(self, other: object) -> bool: 89 | """Whether this object contains all the elements of another object (i.e. is a subset of the other object). 90 | 91 | Parameters: 92 | other: The object to determine if our object contains. 93 | 94 | Raises: 95 | ValueError: If the other object is not of the same type as this object. 96 | """ 97 | if not isinstance(other, self.__class__): 98 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 99 | if self.type != other.type: 100 | return False 101 | if other.value == "*": 102 | return True 103 | if other.is_account and self.account_id == other.account_id: 104 | return True 105 | for self_element, other_element in zip(self.arn_elements, other.arn_elements): 106 | if self_element != other_element and other_element: 107 | return False 108 | return True 109 | 110 | @property 111 | def account_id(self) -> Optional[str]: 112 | """Return the account id of this Principal if there is one.""" 113 | try: 114 | return self.arn_elements[4] 115 | except IndexError: 116 | return None 117 | 118 | @property 119 | def arn_elements(self) -> List[str]: 120 | """Return a list of arn elements, replacing blanks with ``""``.""" 121 | return [element or "" for element in self.value.split(":")] 122 | 123 | @property 124 | def is_account(self) -> bool: 125 | """Return true if the prinncipal is an account.""" 126 | return bool(self.type == "AWS" and re.match(r"^arn:aws:iam::\d+:root$", self.value)) 127 | 128 | @staticmethod 129 | def _normalize_account_id(value: PrincipalValue) -> PrincipalValue: 130 | """Return a fully qualified account id if a value is a short account id. 131 | 132 | Parameters: 133 | value: The value to normalize. 134 | """ 135 | if re.match(r"^\d+$", value): 136 | return PrincipalValue(f"arn:aws:iam::{value}:root") 137 | return PrincipalValue(value) 138 | 139 | def __eq__(self, other: object) -> bool: 140 | """Whether this object contains (but is not equal to) another object. 141 | 142 | Parameters: 143 | other: The object to determine if our object contains. 144 | 145 | Raises: 146 | ValueError: If the other object is not of the same type as this object. 147 | """ 148 | if not isinstance(other, self.__class__): 149 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 150 | return self.type == other.type and self.value == other.value 151 | 152 | def __lt__(self, other: object) -> bool: 153 | """Whether this object contains (but is not equal to) another object. 154 | 155 | Parameters: 156 | other: The object to determine if our object contains. 157 | 158 | Raises: 159 | ValueError: If the other object is not of the same type as this object. 160 | """ 161 | if not isinstance(other, self.__class__): 162 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 163 | if self == other: 164 | return False 165 | return self.issubset(other) 166 | 167 | def __repr__(self) -> str: 168 | """Return an insantiable representation of this object.""" 169 | return f"{self.__class__.__name__}(type='{self.type}', value='{self.value}')" 170 | 171 | def __hash__(self) -> int: 172 | """Return a hash representation of this object.""" 173 | return hash(str(self)) 174 | 175 | def __str__(self) -> str: 176 | """Return a string representation of this object.""" 177 | return f"{self.type} {self.value}" 178 | 179 | 180 | class EffectivePrincipal(EffectiveARP[Principal]): 181 | """EffectivePrincipals are the representation of the difference between an Principal and its exclusion. 182 | 183 | The allowed Principal is the difference (subtraction) of the excluded Principals 184 | from the included Principal. 185 | """ 186 | 187 | _arp_type = Principal 188 | -------------------------------------------------------------------------------- /policyglass/effective_arp.py: -------------------------------------------------------------------------------- 1 | """Parent class for EffectiveAction, EffectiveResource, EffectivePrincipal.""" 2 | from typing import Any, Callable, Dict, FrozenSet, Generic, Iterator, List, Optional, Type, TypeVar, Union 3 | 4 | from .protocols import ARPProtocol 5 | 6 | T = TypeVar("T", bound=ARPProtocol) 7 | 8 | 9 | class EffectiveARP(Generic[T]): 10 | """EffectiveARPs are the representation of the difference between an ARP and its exclusion. 11 | 12 | The allowed actions is the difference (subtraction) of the excluded ARPs 13 | from the included ARP. 14 | """ 15 | 16 | #: Inclusion must be a superset of any exclusions 17 | inclusion: T 18 | 19 | #: Exclusions must always be a subset of the include and must not be subsets of each other 20 | exclusions: FrozenSet[T] 21 | 22 | #: The type of ARP we're subclassed for. 23 | _arp_type: Type[T] 24 | 25 | def __init__(self, inclusion: T, exclusions: Optional[FrozenSet[T]] = None) -> None: 26 | self.inclusion = inclusion 27 | self.exclusions = exclusions or frozenset() 28 | if not all([isinstance(arp, self._arp_type) for arp in [self.inclusion, *self.exclusions]]): 29 | raise ValueError(f"All inclusions and exclusions must be type {self._arp_type.__name__}") 30 | if not (all(exclusion < self.inclusion for exclusion in self.exclusions)): 31 | bad_exclusions = [exclusion for exclusion in self.exclusions if not exclusion.issubset(self.inclusion)] 32 | raise ValueError(f"Exclusions ({bad_exclusions}) are not within the inclusion ({repr(self.inclusion)})") 33 | 34 | def union(self, other: object) -> List["EffectiveARP[T]"]: 35 | """Combine this object with another object of the same type. 36 | 37 | Parameters: 38 | other: The object to combine with this one. 39 | 40 | Raises: 41 | ValueError: If ``other`` is not the same type as this object. 42 | """ 43 | if not isinstance(other, self.__class__): 44 | raise ValueError(f"Cannot union {self.__class__.__name__} with {other.__class__.__name__}") 45 | if self.inclusion.issubset(other.inclusion) and not other.in_exclusions(self.inclusion): 46 | return [other] 47 | if other.inclusion.issubset(self.inclusion) and not self.in_exclusions(other.inclusion): 48 | return [self] 49 | return [self, other] 50 | 51 | def difference(self, other: object) -> List["EffectiveARP[T]"]: 52 | """Calculate the difference between this and another object of the same type. 53 | 54 | Effectively subtracts the inclusions of ``other`` from ``self``. 55 | This is useful when applying denies (``other``) to allows (``self``). 56 | 57 | Parameters: 58 | other: The object to subtract from this one. 59 | 60 | Raises: 61 | ValueError: If ``other`` is not the same type as this object. 62 | """ 63 | if not isinstance(other, self.__class__): 64 | raise ValueError(f"Cannot diff {self.__class__.__name__} with {other.__class__.__name__}") 65 | if self.inclusion.issubset(other.inclusion): 66 | if other.exclusions: 67 | return [ 68 | arp 69 | for arp in [self.__class__.factory(exclusion) for exclusion in other.exclusions] 70 | if arp is not None 71 | ] 72 | return [] 73 | if not other.inclusion.issubset(self.inclusion): 74 | return [self] 75 | if self.in_exclusions(other.inclusion): 76 | return [self] 77 | if not other.exclusions: 78 | # Just add the other's inclusion to self's exclusions 79 | arp = self.__class__.factory(self.inclusion, frozenset(set(self.exclusions).union(set({other.inclusion})))) 80 | return [arp] if arp else [] 81 | # If the other has its own exclusions we need to represent this as two seperate items 82 | # We need to add the inclusion from other to the exclusions of self and create new items for 83 | # each exclusion of other. 84 | # See docs for more details. 85 | new_self = self.__class__.factory(self.inclusion, frozenset(set(self.exclusions).union(set({other.inclusion})))) 86 | new_others = [self.__class__.factory(other_exclusion) for other_exclusion in other.exclusions] 87 | return [arp for arp in [new_self, *new_others] if arp is not None] 88 | 89 | def intersection(self, other: object) -> Optional["EffectiveARP[T]"]: 90 | """Calculate the intersection between this object and another object of the same type. 91 | 92 | Parameters: 93 | other: The object to intersect with this one. 94 | 95 | Raises: 96 | ValueError: if ``other`` is not the same type as this object. 97 | """ 98 | if not isinstance(other, self.__class__): 99 | raise ValueError(f"Cannot intersect {self.__class__.__name__} with {other.__class__.__name__}") 100 | 101 | if not self.inclusion.issubset(other.inclusion) and not other.inclusion.issubset(self.inclusion): 102 | return None 103 | if self.in_exclusions(other.inclusion): 104 | return None 105 | if self.inclusion.issubset(other.inclusion): 106 | if not other.exclusions: 107 | return self 108 | self_with_others_exclusions_added = self.__class__.factory( 109 | self.inclusion, 110 | frozenset( 111 | [ 112 | exclusion 113 | for exclusion in set(self.exclusions).union(set(other.exclusions)) 114 | if exclusion.issubset(self.inclusion) 115 | ] 116 | ), 117 | ) 118 | return self_with_others_exclusions_added 119 | 120 | other_with_self_exclusions_added = self.__class__.factory( 121 | other.inclusion, 122 | frozenset( 123 | [ 124 | exclusion 125 | for exclusion in self.exclusions.union(set(other.exclusions)) 126 | if exclusion.issubset(other.inclusion) 127 | ] 128 | ), 129 | ) 130 | 131 | return other_with_self_exclusions_added 132 | 133 | def issubset(self, other: object) -> bool: 134 | """Whether this object contains all the elements of another object (i.e. is a subset of the other object). 135 | 136 | Parameters: 137 | other: The object to determine if our object contains. 138 | 139 | Raises: 140 | ValueError: If the other object is not of the same type as this object. 141 | """ 142 | if not isinstance(other, self.__class__): 143 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 144 | if self == other: 145 | return True 146 | if not self.inclusion.issubset(other.inclusion): 147 | return False 148 | if other.in_exclusions(self.inclusion): 149 | return False 150 | if self.in_exclusions(other.inclusion) or any( 151 | self_exclusion.issubset(other_exclusion) 152 | for self_exclusion in self.exclusions 153 | for other_exclusion in other.exclusions 154 | ): 155 | return False 156 | 157 | for other_exclusion in other.exclusions: 158 | # If any of other's exclusions excludes something self DOESN'T then self is not a subset of other. 159 | if other_exclusion.issubset(self.inclusion) and not any( 160 | other_exclusion.issubset(self_exclusion) for self_exclusion in self.exclusions 161 | ): 162 | return False 163 | return True 164 | 165 | def in_exclusions(self, other: T) -> bool: 166 | """Check if the ARP is contained within or equal to any of the exclusions. 167 | 168 | Parameters: 169 | other: The object to look for in the exclusions of this object. 170 | """ 171 | return any(other.issubset(exclusion) for exclusion in self.exclusions) 172 | 173 | @classmethod 174 | def factory(cls, inclusion: T, exclusions: Optional[FrozenSet[T]] = None) -> Optional["EffectiveARP[T]"]: 175 | """Return an EffectiveARP[T] based on the inclusion and exclusion. 176 | 177 | This handles the ValueError if we accidentally pass an invalid inclusion/exclusion combo. 178 | 179 | Parameters: 180 | inclusion: The that that is in effect. 181 | exclusions: The list of s that are excluded from the effect. 182 | """ 183 | try: 184 | return cls(inclusion, exclusions) 185 | except ValueError: 186 | return None 187 | 188 | def __contains__(self, other: object) -> bool: 189 | """Whether this object contains the ARP passed in. 190 | 191 | Parameters: 192 | other: The object to determine if our object contains. 193 | 194 | Raises: 195 | ValueError: If the other object is not of the same type as this object. 196 | """ 197 | if not isinstance(other, self._arp_type): 198 | raise ValueError(f"Cannot check if {self.__class__.__name__} contains a {other.__class__.__name__}") 199 | 200 | return self.__class__(other).issubset(self) 201 | 202 | def __eq__(self, other: object) -> bool: 203 | """Whether this object is not equal to another object. 204 | 205 | Parameters: 206 | other: The object to determine if our object is equal to. 207 | 208 | Raises: 209 | ValueError: If the other object is not of the same type as this object. 210 | """ 211 | if not isinstance(other, self.__class__): 212 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 213 | return self.inclusion == other.inclusion and self.exclusions == other.exclusions 214 | 215 | def __lt__(self, other: object) -> bool: 216 | """Whether this object is a proper subset of another object. 217 | 218 | Parameters: 219 | other: The object to determine if our object is a proper subset of. 220 | 221 | Raises: 222 | ValueError: If the other object is not of the same type as this object. 223 | """ 224 | if not isinstance(other, self.__class__): 225 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 226 | return self.issubset(other) and self != other 227 | 228 | def __gt__(self, other: object) -> bool: 229 | """Whether another object is a proper subset of this object. 230 | 231 | Parameters: 232 | other: The object to determine to check if it is a subset of our object. 233 | 234 | Raises: 235 | ValueError: If the other object is not of the same type as this object. 236 | """ 237 | if not isinstance(other, self.__class__): 238 | raise ValueError(f"Cannot compare {self.__class__.__name__} and {other.__class__.__name__}") 239 | return other.issubset(self) and self != other 240 | 241 | def __repr__(self) -> str: 242 | """Return an insantiable representation of this object.""" 243 | return f"{self.__class__.__name__}(inclusion={repr(self.inclusion)}, exclusions={self.exclusions})" 244 | 245 | def dict(self, *args, **kwargs) -> Dict[str, Any]: 246 | """Return a dictionary representation of this object. 247 | 248 | Parameters: 249 | *args: Arguments to Pydantic dict method. 250 | **kwargs: Arguments to Pydantic dict method. 251 | 252 | """ 253 | result: Dict[str, Union[T, FrozenSet[T]]] = {"inclusion": self.inclusion} 254 | if not kwargs.get("exclude_defaults") or self.exclusions != frozenset(): 255 | result.update({"exclusions": self.exclusions}) 256 | return result 257 | 258 | @classmethod 259 | def __get_validators__(cls) -> Iterator[Callable]: 260 | """Return noop validator as we don't particularly need validation. 261 | 262 | We just need Pydantic to accept this as a valid type to populate in PolicyShard. 263 | """ 264 | yield lambda x: x 265 | -------------------------------------------------------------------------------- /tests/unit/intersection/test_policy_shard.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import PolicyShard 4 | from policyglass.action import Action, EffectiveAction 5 | from policyglass.condition import Condition, EffectiveCondition 6 | from policyglass.principal import EffectivePrincipal, Principal 7 | from policyglass.resource import EffectiveResource, Resource 8 | 9 | 10 | def test_bad_intersection(): 11 | with pytest.raises(ValueError) as ex: 12 | PolicyShard( 13 | effect="Allow", 14 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 15 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 16 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 17 | ).intersection(Action("S3:*")) 18 | 19 | assert "Cannot intersect PolicyShard with Action" in str(ex.value) 20 | 21 | 22 | INTERSECTION_SCENARIOS = { 23 | "test_subset": { 24 | "first": PolicyShard( 25 | effect="Allow", 26 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 27 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 28 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 29 | ), 30 | "second": PolicyShard( 31 | effect="Allow", 32 | effective_action=EffectiveAction(inclusion=Action("s3:GetObject"), exclusions=frozenset()), 33 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 34 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 35 | ), 36 | "result": PolicyShard( 37 | effect="Allow", 38 | effective_action=EffectiveAction(inclusion=Action("s3:GetObject"), exclusions=frozenset()), 39 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 40 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 41 | ), 42 | }, 43 | "test_exactly_equal": { 44 | "first": PolicyShard( 45 | effect="Allow", 46 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 47 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 48 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 49 | ), 50 | "second": PolicyShard( 51 | effect="Allow", 52 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 53 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 54 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 55 | ), 56 | "result": PolicyShard( 57 | effect="Allow", 58 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 59 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 60 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 61 | ), 62 | }, 63 | "test_disjoint_conditions": { 64 | "first": PolicyShard( 65 | effect="Allow", 66 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 67 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 68 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 69 | effective_condition=EffectiveCondition( 70 | frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}) 71 | ), 72 | ), 73 | "second": PolicyShard( 74 | effect="Allow", 75 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 76 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 77 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 78 | effective_condition=EffectiveCondition( 79 | frozenset( 80 | { 81 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 82 | } 83 | ) 84 | ), 85 | ), 86 | "result": None, 87 | }, 88 | "test_matching_equal_one_with_one_without_condition": { 89 | "first": PolicyShard( 90 | effect="Allow", 91 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 92 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 93 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 94 | ), 95 | "second": PolicyShard( 96 | effect="Allow", 97 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 98 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 99 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 100 | effective_condition=EffectiveCondition( 101 | frozenset( 102 | { 103 | Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 104 | } 105 | ) 106 | ), 107 | ), 108 | "result": PolicyShard( 109 | effect="Allow", 110 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 111 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 112 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 113 | ), 114 | }, 115 | "test_matching_subset_conditions_larger_first": { 116 | "first": PolicyShard( 117 | effect="Allow", 118 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 119 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 120 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 121 | effective_condition=EffectiveCondition( 122 | frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}) 123 | ), 124 | ), 125 | "second": PolicyShard( 126 | effect="Allow", 127 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 128 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 129 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 130 | effective_condition=EffectiveCondition( 131 | frozenset( 132 | { 133 | Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 134 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 135 | } 136 | ) 137 | ), 138 | ), 139 | "result": PolicyShard( 140 | effect="Allow", 141 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 142 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 143 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 144 | effective_condition=EffectiveCondition( 145 | frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}) 146 | ), 147 | ), 148 | }, 149 | "test_matching_subset_conditions_smaller_first": { 150 | "first": PolicyShard( 151 | effect="Allow", 152 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 153 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 154 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 155 | effective_condition=EffectiveCondition( 156 | frozenset( 157 | { 158 | Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 159 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 160 | } 161 | ) 162 | ), 163 | ), 164 | "second": PolicyShard( 165 | effect="Allow", 166 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 167 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 168 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 169 | effective_condition=EffectiveCondition( 170 | frozenset({Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"])}) 171 | ), 172 | ), 173 | "result": PolicyShard( 174 | effect="Allow", 175 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:PutObject")})), 176 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 177 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 178 | effective_condition=EffectiveCondition( 179 | frozenset( 180 | { 181 | Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"]), 182 | Condition(key="aws:PrincipalOrgId", operator="StringNotEquals", values=["o-123456"]), 183 | } 184 | ) 185 | ), 186 | ), 187 | }, 188 | "test_allow_without_condition_vs_deny_with_condition": { 189 | "first": PolicyShard( 190 | effect="Allow", 191 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 192 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 193 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 194 | ), 195 | "second": PolicyShard( 196 | effect="Deny", 197 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 198 | effective_resource=EffectiveResource( 199 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 200 | ), 201 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 202 | effective_condition=EffectiveCondition( 203 | frozenset( 204 | {Condition(key="s3:x-amz-server-side-encryption", operator="StringNotEquals", values=["AES256"])} 205 | ) 206 | ), 207 | ), 208 | "result": PolicyShard( 209 | effect="Allow", 210 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 211 | effective_resource=EffectiveResource( 212 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 213 | ), 214 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 215 | ), 216 | }, 217 | } 218 | 219 | 220 | @pytest.mark.parametrize("_, scenario", INTERSECTION_SCENARIOS.items()) 221 | def test_intersection(_, scenario): 222 | first, second, result = scenario.values() 223 | assert first.intersection(second) == result 224 | -------------------------------------------------------------------------------- /tests/unit/issubset/test_policy_shard.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from policyglass import Action, EffectiveAction, EffectivePrincipal, EffectiveResource, PolicyShard, Principal, Resource 4 | from policyglass.condition import Condition, EffectiveCondition 5 | 6 | POLICYS_SHARD_ISSUBSET_SCENARIOS = { 7 | "smaller": [ 8 | PolicyShard( 9 | effect="Allow", 10 | effective_action=EffectiveAction(inclusion=Action("s3:get*"), exclusions=frozenset()), 11 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 12 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 13 | ), 14 | PolicyShard( 15 | effect="Allow", 16 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 17 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 18 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 19 | ), 20 | ], 21 | "smaller_by_arp_exclusion": [ 22 | PolicyShard( 23 | effect="Allow", 24 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset({Action("s3:Get*")})), 25 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 26 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 27 | effective_condition=EffectiveCondition(inclusions=frozenset(), exclusions=frozenset()), 28 | ), 29 | PolicyShard( 30 | effect="Allow", 31 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 32 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 33 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 34 | effective_condition=EffectiveCondition(inclusions=frozenset(), exclusions=frozenset()), 35 | ), 36 | ], 37 | "exactly_equal": [ 38 | PolicyShard( 39 | effect="Allow", 40 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 41 | effective_resource=EffectiveResource( 42 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 43 | ), 44 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 45 | ), 46 | PolicyShard( 47 | effect="Allow", 48 | effective_action=EffectiveAction(inclusion=Action("s3:PutObject"), exclusions=frozenset()), 49 | effective_resource=EffectiveResource( 50 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 51 | ), 52 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 53 | ), 54 | ], 55 | "exactly_equal_but_condition": [ 56 | PolicyShard( 57 | effect="Allow", 58 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 59 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 60 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 61 | effective_condition=EffectiveCondition( 62 | frozenset({Condition(key="TestKey", operator="TestOperator", values=["TestValue"])}) 63 | ), 64 | ), 65 | PolicyShard( 66 | effect="Allow", 67 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 68 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 69 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 70 | ), 71 | ], 72 | "exactly_equal_but_condition_exclusion": [ 73 | PolicyShard( 74 | effect="Allow", 75 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 76 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 77 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 78 | effective_condition=EffectiveCondition( 79 | exclusions=frozenset( 80 | {Condition(key="Key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])} 81 | ) 82 | ), 83 | ), 84 | PolicyShard( 85 | effect="Allow", 86 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 87 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 88 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 89 | ), 90 | ], 91 | "exactly_equal_but_more_conditions": [ 92 | PolicyShard( 93 | effect="Allow", 94 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 95 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 96 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 97 | effective_condition=EffectiveCondition( 98 | frozenset( 99 | { 100 | Condition(key="TestKey", operator="TestOperator", values=["TestValue"]), 101 | Condition(key="OtherTestKey", operator="TestOperator", values=["TestValue"]), 102 | } 103 | ) 104 | ), 105 | ), 106 | PolicyShard( 107 | effect="Allow", 108 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 109 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 110 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 111 | effective_condition=EffectiveCondition( 112 | frozenset({Condition(key="TestKey", operator="TestOperator", values=["TestValue"])}) 113 | ), 114 | ), 115 | ], 116 | "exactly_equal_but_one_has_condition_exclusions": [ 117 | PolicyShard( 118 | effect="Allow", 119 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 120 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 121 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 122 | effective_condition=EffectiveCondition( 123 | inclusions=frozenset( 124 | {Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"])} 125 | ), 126 | exclusions=frozenset( 127 | {Condition(key="Key", operator="BinaryEquals", values=["QmluYXJ5VmFsdWVJbkJhc2U2NA=="])} 128 | ), 129 | ), 130 | ), 131 | PolicyShard( 132 | effect="Allow", 133 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 134 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 135 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 136 | effective_condition=EffectiveCondition( 137 | frozenset( 138 | {Condition(key="s3:x-amz-server-side-encryption", operator="StringEquals", values=["AES256"])} 139 | ) 140 | ), 141 | ), 142 | ], 143 | } 144 | 145 | 146 | @pytest.mark.parametrize("_, scenario", POLICYS_SHARD_ISSUBSET_SCENARIOS.items()) 147 | def test_policy_shard_issubset(_, scenario): 148 | assert scenario[0].issubset(scenario[1]) 149 | 150 | 151 | POLICY_SHARD_NOT_ISSUBSET_SCENARIOS = { 152 | "larger": [ 153 | PolicyShard( 154 | effect="Allow", 155 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 156 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 157 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 158 | ), 159 | PolicyShard( 160 | effect="Allow", 161 | effective_action=EffectiveAction(inclusion=Action("s3:get*"), exclusions=frozenset()), 162 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 163 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 164 | ), 165 | ], 166 | "equal_with_opposite_effects": [ 167 | PolicyShard( 168 | effect="Allow", 169 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 170 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 171 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 172 | ), 173 | PolicyShard( 174 | effect="Deny", 175 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 176 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 177 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 178 | ), 179 | ], 180 | "action_not_subset_resource_is": [ 181 | PolicyShard( 182 | effect="Allow", 183 | effective_action=EffectiveAction(inclusion=Action("*"), exclusions=frozenset()), 184 | effective_resource=EffectiveResource( 185 | inclusion=Resource("*"), exclusions=frozenset({Resource("arn:aws:s3:::examplebucket/*")}) 186 | ), 187 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 188 | ), 189 | PolicyShard( 190 | effect="Allow", 191 | effective_action=EffectiveAction(inclusion=Action("*"), exclusions=frozenset({Action("s3:PutObject")})), 192 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 193 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 194 | ), 195 | ], 196 | "equal_except_for_exclusion": [ 197 | PolicyShard( 198 | effect="Allow", 199 | effective_action=EffectiveAction(inclusion=Action("s3:*")), 200 | effective_resource=EffectiveResource(inclusion=Resource("*")), 201 | effective_principal=EffectivePrincipal(Principal("AWS", "arn:aws:iam::123456789012:role/RoleName")), 202 | ), 203 | PolicyShard( 204 | effect="Allow", 205 | effective_action=EffectiveAction(inclusion=Action("s3:*")), 206 | effective_resource=EffectiveResource(inclusion=Resource("*")), 207 | effective_principal=EffectivePrincipal( 208 | Principal("AWS", "*"), frozenset({Principal("AWS", "arn:aws:iam::123456789012:root")}) 209 | ), 210 | ), 211 | ], 212 | "exactly_equal_but_differing_conditions": [ 213 | PolicyShard( 214 | effect="Allow", 215 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 216 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 217 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 218 | effective_condition=EffectiveCondition( 219 | frozenset( 220 | { 221 | Condition(key="SomesTestKey", operator="TestOperator", values=["TestValue"]), 222 | Condition(key="OtherTestKey", operator="TestOperator", values=["TestValue"]), 223 | } 224 | ) 225 | ), 226 | ), 227 | PolicyShard( 228 | effect="Allow", 229 | effective_action=EffectiveAction(inclusion=Action("s3:*"), exclusions=frozenset()), 230 | effective_resource=EffectiveResource(inclusion=Resource("*"), exclusions=frozenset()), 231 | effective_principal=EffectivePrincipal(inclusion=Principal(type="AWS", value="*"), exclusions=frozenset()), 232 | effective_condition=EffectiveCondition( 233 | frozenset({Condition(key="TestKey", operator="TestOperator", values=["TestValue"])}) 234 | ), 235 | ), 236 | ], 237 | } 238 | 239 | 240 | @pytest.mark.parametrize("_, scenario", POLICY_SHARD_NOT_ISSUBSET_SCENARIOS.items()) 241 | def test_policy_shard_not_issubset(_, scenario): 242 | assert not scenario[0].issubset(scenario[1]) 243 | --------------------------------------------------------------------------------