From 12495e4b42146916b4604021ef32b093c14a1ab7 Mon Sep 17 00:00:00 2001 From: Ted Timmons Date: Thu, 5 Jan 2017 06:42:59 -0800 Subject: [PATCH] New module: aws_kms for managing access grants on AWS KMS keys (#19309) New module by @tedder for handling granting/revoking access to KMS secrets. For example: ``` - name: grant user-style access to production secrets kms: args: mode: grant key_alias: "alias/my_production_secrets" role_name: "prod-appServerRole-1R5AQG2BSEL6L" grant_types: "role,role grant" ``` --- lib/ansible/modules/cloud/amazon/aws_kms.py | 299 ++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 lib/ansible/modules/cloud/amazon/aws_kms.py diff --git a/lib/ansible/modules/cloud/amazon/aws_kms.py b/lib/ansible/modules/cloud/amazon/aws_kms.py new file mode 100644 index 00000000000..58dabe496e0 --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/aws_kms.py @@ -0,0 +1,299 @@ +#!/usr/bin/python +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = { + 'version': '1.0', + 'status': ['preview'], + 'supported_by': 'committer' +} + +DOCUMENTATION = ''' +--- +module: kms +short_description: Perform various KMS management tasks. +description: + - Manage role/user access to a KMS key. Not designed for encrypting/decrypting. +version_added: "2.3" +options: + mode: + description: + - Grant or deny access. + required: true + default: grant + choices: [ grant, deny ] + key_alias: + description: + - Alias label to the key. One of C(key_alias) or C(key_arn) are required. + required: false + key_arn: + description: + - Full ARN to the key. One of C(key_alias) or C(key_arn) are required. + required: false + role_name: + description: + - Role to allow/deny access. One of C(role_name) or C(role_arn) are required. + required: false + role_arn: + description: + - ARN of role to allow/deny access. One of C(role_name) or C(role_arn) are required. + required: false + grant_types: + description: + - List of grants to give to user/role. Likely "role,role grant" or "role,role grant,admin". Required when C(mode=grant). + required: false + clean_invalid_entries: + description: + - If adding/removing a role and invalid grantees are found, remove them. These entries will cause an update to fail in all known cases. + - Only cleans if changes are being made. + type: bool + default: true + +author: tedder +extends_documentation_fragment: +- aws +- ec2 +''' + +EXAMPLES = ''' +- name: grant user-style access to production secrets + kms: + args: + mode: grant + key_alias: "alias/my_production_secrets" + role_name: "prod-appServerRole-1R5AQG2BSEL6L" + grant_types: "role,role grant" +- name: remove access to production secrets from role + kms: + args: + mode: deny + key_alias: "alias/my_production_secrets" + role_name: "prod-appServerRole-1R5AQG2BSEL6L" +''' + +RETURN = ''' +changes_needed: + description: grant types that would be changed/were changed. + type: dict + returned: always + sample: { "role": "add", "role grant": "add" } +had_invalid_entries: + description: there are invalid (non-ARN) entries in the KMS entry. These don't count as a change, but will be removed if any changes are being made. + type: boolean + returned: always +''' + +# these mappings are used to go from simple labels to the actual 'Sid' values returned +# by get_policy. They seem to be magic values. +statement_label = { + 'role': 'Allow use of the key', + 'role grant': 'Allow attachment of persistent resources', + 'admin': 'Allow access for Key Administrators' +} + +# import module snippets +from ansible.module_utils.basic import AnsibleModule + +# import a class, we'll use a fully qualified path +import ansible.module_utils.ec2 + +import traceback +import json + +try: + import botocore + HAS_BOTO3 = True +except ImportError: + HAS_BOTO3 = False + +def boto_exception(err): + '''generic error message handler''' + if hasattr(err, 'error_message'): + error = err.error_message + elif hasattr(err, 'message'): + error = str(err.message) + ' ' + str(err) + ' - ' + str(type(err)) + else: + error = '%s: %s' % (Exception, err) + + return error + +def get_arn_from_kms_alias(kms, aliasname): + ret = kms.list_aliases() + key_id = None + for a in ret['Aliases']: + if a['AliasName'] == aliasname: + key_id = a['TargetKeyId'] + break + if not key_id: + raise Exception('could not find alias {}'.format(aliasname)) + + # now that we have the ID for the key, we need to get the key's ARN. The alias + # has an ARN but we need the key itself. + ret = kms.list_keys() + for k in ret['Keys']: + if k['KeyId'] == key_id: + return k['KeyArn'] + raise Exception('could not find key from id: {}'.format(key_id)) + +def get_arn_from_role_name(iam, rolename): + ret = iam.get_role(RoleName=rolename) + if ret.get('Role') and ret['Role'].get('Arn'): + return ret['Role']['Arn'] + raise Exception('could not find arn for name {}.'.format(rolename)) + +def do_grant(kms, keyarn, role_arn, granttypes, mode='grant', dry_run=True, clean_invalid_entries=True): + ret = {} + keyret = kms.get_key_policy(KeyId=keyarn, PolicyName='default') + policy = json.loads(keyret['Policy']) + + changes_needed = {} + assert_policy_shape(policy) + had_invalid_entries = False + for statement in policy['Statement']: + for granttype in ['role', 'role grant', 'admin']: + # do we want this grant type? Are we on its statement? + # and does the role have this grant type? + + if mode == 'grant' and statement['Sid'] == statement_label[granttype]: + # we're granting and we recognize this statement ID. + + if granttype in granttypes: + invalid_entries = list(filter(lambda x: not x.startswith('arn:aws:iam::'), statement['Principal']['AWS'])) + if clean_invalid_entries and len(list(invalid_entries)): + # we have bad/invalid entries. These are roles that were deleted. + # prune the list. + valid_entries = filter(lambda x: x.startswith('arn:aws:iam::'), statement['Principal']['AWS']) + statement['Principal']['AWS'] = valid_entries + had_invalid_entries = True + + + if not role_arn in statement['Principal']['AWS']: # needs to be added. + changes_needed[granttype] = 'add' + if not dry_run: + statement['Principal']['AWS'].append(role_arn) + elif role_arn in statement['Principal']['AWS']: # not one the places the role should be + changes_needed[granttype] = 'remove' + if not dry_run: + statement['Principal']['AWS'].remove(role_arn) + + elif mode == 'deny' and statement['Sid'] == statement_label[granttype] and role_arn in statement['Principal']['AWS']: + # we don't selectively deny. that's a grant with a + # smaller list. so deny=remove all of this arn. + changes_needed[granttype] = 'remove' + if not dry_run: + statement['Principal']['AWS'].remove(role_arn) + + try: + if len(changes_needed) and not dry_run: + policy_json_string = json.dumps(policy) + kms.put_key_policy(KeyId=keyarn, PolicyName='default', Policy=policy_json_string) + except: + raise Exception("{}: // {}".format("e", policy_json_string)) + + # returns nothing, so we have to just assume it didn't throw + ret['changed'] = True + + ret['changes_needed'] = changes_needed + ret['had_invalid_entries'] = had_invalid_entries + if dry_run: + # true if changes > 0 + ret['changed'] = (not len(changes_needed) == 0) + + return ret + +def assert_policy_shape(policy): + '''Since the policy seems a little, uh, fragile, make sure we know approximately what we're looking at.''' + errors = [] + if policy['Version'] != "2012-10-17": + errors.append('Unknown version/date ({}) of policy. Things are probably different than we assumed they were.'.format(policy['Version'])) + + found_statement_type = {} + for statement in policy['Statement']: + for label,sidlabel in statement_label.items(): + if statement['Sid'] == sidlabel: + found_statement_type[label] = True + + for statementtype in statement_label.keys(): + if not found_statement_type.get(statementtype): + errors.append('Policy is missing {}.'.format(statementtype)) + + if len(errors): + raise Exception('Problems asserting policy shape. Cowardly refusing to modify it: {}'.format(' '.join(errors))) + return None + +def main(): + argument_spec = ansible.module_utils.ec2.ec2_argument_spec() + argument_spec.update(dict( + mode = dict(choices=['grant', 'deny'], default='grant'), + key_alias = dict(required=False, type='str'), + key_arn = dict(required=False, type='str'), + role_name = dict(required=False, type='str'), + role_arn = dict(required=False, type='str'), + grant_types = dict(required=False, type='list'), + clean_invalid_entries = dict(type='bool', default=True), + ) + ) + + module = AnsibleModule( + supports_check_mode=True, + argument_spec=argument_spec, + required_one_of=[['key_alias', 'key_arn'], ['role_name', 'role_arn']], + required_if=[['mode', 'grant', ['grant_types']]] + ) + if not HAS_BOTO3: + module.fail_json(msg='boto3 required for this module') + + result = {} + mode = module.params['mode'] + + + try: + region, ec2_url, aws_connect_kwargs = ansible.module_utils.ec2.get_aws_connection_info(module, boto3=True) + kms = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='kms', region=region, endpoint=ec2_url, **aws_connect_kwargs) + iam = ansible.module_utils.ec2.boto3_conn(module, conn_type='client', resource='iam', region=region, endpoint=ec2_url, **aws_connect_kwargs) + except botocore.exceptions.NoCredentialsError as e: + module.fail_json(msg='cannot connect to AWS', exception=traceback.format_exc(e)) + + + try: + if module.params['key_alias'] and not module.params['key_arn']: + module.params['key_arn'] = get_arn_from_kms_alias(kms, module.params['key_alias']) + if not module.params['key_arn']: + module.fail_json(msg='key_arn or key_alias is required to {}'.format(mode)) + + if module.params['role_name'] and not module.params['role_arn']: + module.params['role_arn'] = get_arn_from_role_name(iam, module.params['role_name']) + if not module.params['role_arn']: + module.fail_json(msg='role_arn or role_name is required to {}'.format(module.params['mode'])) + + # check the grant types for 'grant' only. + if mode == 'grant': + for g in module.params['grant_types']: + if not g in statement_label: + module.fail_json(msg='{} is an unknown grant type.'.format(g)) + + ret = do_grant(kms, module.params['key_arn'], module.params['role_arn'], module.params['grant_types'], mode=mode, dry_run=module.check_mode, clean_invalid_entries=module.params['clean_invalid_entries']) + result.update(ret) + + except Exception as err: + error_msg = boto_exception(err) + module.fail_json(msg=error_msg, exception=traceback.format_exc(err)) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() +