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" ```
This commit is contained in:
parent
5a14f1d705
commit
12495e4b42
1 changed files with 299 additions and 0 deletions
299
lib/ansible/modules/cloud/amazon/aws_kms.py
Normal file
299
lib/ansible/modules/cloud/amazon/aws_kms.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
Loading…
Reference in a new issue