aws_kms enhancements (#31960)

* Allow creation and deletion of keys (deletion just schedules for
  deletion, recreating an old key is just cancelling its deletion)
* Allow grants to be set, thus enabling encryption contexts to be
  used with keys
* Allow tags to be added and modified
* Add testing for KMS module
* Tidy up aws_kms module to latest standards
This commit is contained in:
Will Thames 2019-02-13 13:06:58 +10:00 committed by GitHub
parent 1f3a74c0c8
commit 46fbcf08bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1136 additions and 54 deletions

1
.gitignore vendored
View file

@ -86,6 +86,7 @@ packaging/release/ansible_release
/test/results/junit/*.xml
/test/results/logs/*.log
/test/results/data/*.json
/test/integration/cloud-config-aws.yml
/test/integration/inventory.remote
/test/integration/inventory.networking
/test/integration/inventory.winrm

View file

@ -0,0 +1,2 @@
minor_changes:
- aws_kms is now able to create keys and manage grants and tags

View file

@ -0,0 +1,54 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAccessToUnspecifiedKMSResources",
"Effect": "Allow",
"Action": [
"iam:ListRoles",
"kms:CancelKeyDeletion",
"kms:CreateAlias",
"kms:CreateGrant",
"kms:CreateKey",
"kms:DeleteAlias",
"kms:Describe*",
"kms:DisableKey",
"kms:EnableKey",
"kms:GenerateRandom",
"kms:Get*",
"kms:List*",
"kms:RetireGrant",
"kms:ScheduleKeyDeletion",
"kms:TagResource",
"kms:UntagResource",
"kms:UpdateGrant",
"kms:UpdateKeyDescription"
],
"Resource": "*"
},
{
"Sid": "AllowAccessToSpecifiedIAMResources",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:GetRole",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:PassRole",
"iam:UpdateAssumeRolePolicy"
],
"Resource": "arn:aws:iam::{{aws_account}}:role/ansible-test-*"
},
{
"Sid": "AllowInstanceProfileCreation",
"Effect": "Allow",
"Action": [
"iam:AddRoleToInstanceProfile",
"iam:CreateInstanceProfile",
"iam:RemoveRoleFromInstanceProfile"
],
"Resource": "arn:aws:iam::{{aws_account}}:instance-profile/ansible-test-*"
}
]
}

View file

@ -26,22 +26,29 @@ 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"
requirements: [ boto3 ]
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.
alias:
description: An alias for a key. For safety, even though KMS does not require keys
to have an alias, this module expects all new keys to be given an alias
to make them easier to manage. Existing keys without an alias may be
referred to by I(key_id). Use M(aws_kms_facts) to find key ids. Required
if I(key_id) is not given. Note that passing a I(key_id) and I(alias)
will only cause a new alias to be added, an alias will never be renamed.
The 'alias/' prefix is optional.
required: false
key_arn:
aliases:
- key_alias
key_id:
description:
- Full ARN to the key. One of C(key_alias) or C(key_arn) are required.
- Key ID or ARN of the key. One of C(alias) or C(key_id) are required.
required: false
aliases:
- key_arn
role_name:
description:
- Role to allow/deny access. One of C(role_name) or C(role_arn) are required.
@ -60,8 +67,62 @@ options:
- Only cleans if changes are being made.
type: bool
default: true
author: Ted Timmons (@tedder)
state:
description: Whether a key should be present or absent. Note that making an
existing key absent only schedules a key for deletion. Passing a key that
is scheduled for deletion with state present will cancel key deletion.
required: False
choices:
- present
- absent
default: present
version_added: 2.8
enabled:
description: Whether or not a key is enabled
default: True
version_added: 2.8
type: bool
description:
description:
A description of the CMK. Use a description that helps you decide
whether the CMK is appropriate for a task.
version_added: 2.8
tags:
description: A dictionary of tags to apply to a key.
version_added: 2.8
purge_tags:
description: Whether the I(tags) argument should cause tags not in the list to
be removed
version_added: 2.8
default: False
type: bool
purge_grants:
description: Whether the I(grants) argument should cause grants not in the list to
be removed
default: False
version_added: 2.8
type: bool
grants:
description:
- A list of grants to apply to the key. Each item must contain I(grantee_principal).
Each item can optionally contain I(retiring_principal), I(operations), I(constraints),
I(name).
- Valid operations are C(Decrypt), C(Encrypt), C(GenerateDataKey), C(GenerateDataKeyWithoutPlaintext),
C(ReEncryptFrom), C(ReEncryptTo), C(CreateGrant), C(RetireGrant), C(DescribeKey), C(Verify) and
C(Sign)
- Constraints is a dict containing C(encryption_context_subset) or C(encryption_context_equals),
either or both being a dict specifying an encryption context match.
See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html)
- I(grantee_principal) and I(retiring_principal) must be ARNs
version_added: 2.8
policy:
description:
- policy to apply to the KMS key
- See U(https://docs.aws.amazon.com/kms/latest/developerguide/key-policies.html)
version_added: 2.8
author:
- Ted Timmons (@tedder)
- Will Thames (@willthames)
extends_documentation_fragment:
- aws
- ec2
@ -72,18 +133,199 @@ EXAMPLES = '''
aws_kms:
args:
mode: grant
key_alias: "alias/my_production_secrets"
alias: "alias/my_production_secrets"
role_name: "prod-appServerRole-1R5AQG2BSEL6L"
grant_types: "role,role grant"
- name: remove access to production secrets from role
aws_kms:
args:
mode: deny
key_alias: "alias/my_production_secrets"
alias: "alias/my_production_secrets"
role_name: "prod-appServerRole-1R5AQG2BSEL6L"
# Create a new KMS key
- aws_kms:
alias: mykey
tags:
Name: myKey
Purpose: protect_stuff
# Update previous key with more tags
- aws_kms:
alias: mykey
tags:
Name: myKey
Purpose: protect_stuff
Owner: security_team
# Update a known key with grants allowing an instance with the billing-prod IAM profile
# to decrypt data encrypted with the environment: production, application: billing
# encryption context
- aws_kms:
key_id: abcd1234-abcd-1234-5678-ef1234567890
grants:
- name: billing_prod
grantee_principal: arn:aws:iam::1234567890123:role/billing_prod
constraints:
encryption_context_equals:
environment: production
application: billing
operations:
- Decrypt
- RetireGrant
'''
RETURN = '''
key_id:
description: ID of key
type: str
returned: always
sample: abcd1234-abcd-1234-5678-ef1234567890
key_arn:
description: ARN of key
type: str
returned: always
sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890
key_state:
description: The state of the key
type: str
returned: always
sample: PendingDeletion
key_usage:
description: The cryptographic operations for which you can use the key.
type: str
returned: always
sample: ENCRYPT_DECRYPT
origin:
description: The source of the key's key material. When this value is C(AWS_KMS),
AWS KMS created the key material. When this value is C(EXTERNAL), the
key material was imported or the CMK lacks key material.
type: str
returned: always
sample: AWS_KMS
aws_account_id:
description: The AWS Account ID that the key belongs to
type: str
returned: always
sample: 1234567890123
creation_date:
description: Date of creation of the key
type: str
returned: always
sample: "2017-04-18T15:12:08.551000+10:00"
description:
description: Description of the key
type: str
returned: always
sample: "My Key for Protecting important stuff"
enabled:
description: Whether the key is enabled. True if C(KeyState) is true.
type: str
returned: always
sample: false
aliases:
description: list of aliases associated with the key
type: list
returned: always
sample:
- aws/acm
- aws/ebs
policies:
description: list of policy documents for the keys. Empty when access is denied even if there are policies.
type: list
returned: always
sample:
Version: "2012-10-17"
Id: "auto-ebs-2"
Statement:
- Sid: "Allow access through EBS for all principals in the account that are authorized to use EBS"
Effect: "Allow"
Principal:
AWS: "*"
Action:
- "kms:Encrypt"
- "kms:Decrypt"
- "kms:ReEncrypt*"
- "kms:GenerateDataKey*"
- "kms:CreateGrant"
- "kms:DescribeKey"
Resource: "*"
Condition:
StringEquals:
kms:CallerAccount: "111111111111"
kms:ViaService: "ec2.ap-southeast-2.amazonaws.com"
- Sid: "Allow direct access to key metadata to the account"
Effect: "Allow"
Principal:
AWS: "arn:aws:iam::111111111111:root"
Action:
- "kms:Describe*"
- "kms:Get*"
- "kms:List*"
- "kms:RevokeGrant"
Resource: "*"
tags:
description: dictionary of tags applied to the key
type: dict
returned: always
sample:
Name: myKey
Purpose: protecting_stuff
grants:
description: list of grants associated with a key
type: complex
returned: always
contains:
constraints:
description: Constraints on the encryption context that the grant allows.
See U(https://docs.aws.amazon.com/kms/latest/APIReference/API_GrantConstraints.html) for further details
type: dict
returned: always
sample:
encryption_context_equals:
"aws:lambda:_function_arn": "arn:aws:lambda:ap-southeast-2:012345678912:function:xyz"
creation_date:
description: Date of creation of the grant
type: str
returned: always
sample: 2017-04-18T15:12:08+10:00
grant_id:
description: The unique ID for the grant
type: str
returned: always
sample: abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234
grantee_principal:
description: The principal that receives the grant's permissions
type: str
returned: always
sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz
issuing_account:
description: The AWS account under which the grant was issued
type: str
returned: always
sample: arn:aws:iam::01234567890:root
key_id:
description: The key ARN to which the grant applies.
type: str
returned: always
sample: arn:aws:kms:ap-southeast-2:123456789012:key/abcd1234-abcd-1234-5678-ef1234567890
name:
description: The friendly name that identifies the grant
type: str
returned: always
sample: xyz
operations:
description: The list of operations permitted by the grant
type: list
returned: always
sample:
- Decrypt
- RetireGrant
retiring_principal:
description: The principal that can retire the grant
type: str
returned: always
sample: arn:aws:sts::0123456789012:assumed-role/lambda_xyz/xyz
changes_needed:
description: grant types that would be changed/were changed.
type: dict
@ -103,22 +345,385 @@ statement_label = {
'admin': 'Allow access for Key Administrators'
}
# import module snippets
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.ec2 import boto_exception
from ansible.module_utils.aws.core import AnsibleAWSModule, is_boto3_error_code
from ansible.module_utils.ec2 import ec2_argument_spec
from ansible.module_utils.ec2 import AWSRetry, camel_dict_to_snake_dict
from ansible.module_utils.ec2 import boto3_tag_list_to_ansible_dict, ansible_dict_to_boto3_tag_list
from ansible.module_utils.ec2 import compare_aws_tags
from ansible.module_utils.six import string_types
# 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
pass # caught by AnsibleAWSModule
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_iam_roles_with_backoff(connection):
paginator = connection.get_paginator('list_roles')
return paginator.paginate().build_full_result()
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_kms_keys_with_backoff(connection):
paginator = connection.get_paginator('list_keys')
return paginator.paginate().build_full_result()
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_kms_aliases_with_backoff(connection):
paginator = connection.get_paginator('list_aliases')
return paginator.paginate().build_full_result()
def get_kms_aliases_lookup(connection):
_aliases = dict()
for alias in get_kms_aliases_with_backoff(connection)['Aliases']:
# Not all aliases are actually associated with a key
if 'TargetKeyId' in alias:
# strip off leading 'alias/' and add it to key's aliases
if alias['TargetKeyId'] in _aliases:
_aliases[alias['TargetKeyId']].append(alias['AliasName'][6:])
else:
_aliases[alias['TargetKeyId']] = [alias['AliasName'][6:]]
return _aliases
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_kms_tags_with_backoff(connection, key_id, **kwargs):
return connection.list_resource_tags(KeyId=key_id, **kwargs)
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_kms_grants_with_backoff(connection, key_id):
params = dict(KeyId=key_id)
paginator = connection.get_paginator('list_grants')
return paginator.paginate(**params).build_full_result()
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_kms_metadata_with_backoff(connection, key_id):
return connection.describe_key(KeyId=key_id)
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def list_key_policies_with_backoff(connection, key_id):
paginator = connection.get_paginator('list_key_policies')
return paginator.paginate(KeyId=key_id).build_full_result()
@AWSRetry.backoff(tries=5, delay=5, backoff=2.0)
def get_key_policy_with_backoff(connection, key_id, policy_name):
return connection.get_key_policy(KeyId=key_id, PolicyName=policy_name)
def get_kms_tags(connection, module, key_id):
# Handle pagination here as list_resource_tags does not have
# a paginator
kwargs = {}
tags = []
more = True
while more:
try:
tag_response = get_kms_tags_with_backoff(connection, key_id, **kwargs)
tags.extend(tag_response['Tags'])
except is_boto3_error_code('AccessDeniedException'):
tag_response = {}
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
module.fail_json_aws(e, msg="Failed to obtain key tags")
if tag_response.get('NextMarker'):
kwargs['Marker'] = tag_response['NextMarker']
else:
more = False
return tags
def get_kms_policies(connection, module, key_id):
try:
policies = list_key_policies_with_backoff(connection, key_id)['PolicyNames']
return [get_key_policy_with_backoff(connection, key_id, policy)['Policy'] for
policy in policies]
except is_boto3_error_code('AccessDeniedException'):
return []
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: # pylint: disable=duplicate-except
module.fail_json_aws(e, msg="Failed to obtain key policies")
def key_matches_filter(key, filtr):
if filtr[0] == 'key-id':
return filtr[1] == key['key_id']
if filtr[0] == 'tag-key':
return filtr[1] in key['tags']
if filtr[0] == 'tag-value':
return filtr[1] in key['tags'].values()
if filtr[0] == 'alias':
return filtr[1] in key['aliases']
if filtr[0].startswith('tag:'):
return key['Tags'][filtr[0][4:]] == filtr[1]
def key_matches_filters(key, filters):
if not filters:
return True
else:
return all([key_matches_filter(key, filtr) for filtr in filters.items()])
def camel_to_snake_grant(grant):
''' camel_to_snake_grant snakifies everything except the encryption context '''
constraints = grant.get('Constraints', {})
result = camel_dict_to_snake_dict(grant)
if 'EncryptionContextEquals' in constraints:
result['constraints']['encryption_context_equals'] = constraints['EncryptionContextEquals']
if 'EncryptionContextSubset' in constraints:
result['constraints']['encryption_context_subset'] = constraints['EncryptionContextSubset']
return result
def get_key_details(connection, module, key_id):
try:
result = get_kms_metadata_with_backoff(connection, key_id)['KeyMetadata']
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to obtain key metadata")
result['KeyArn'] = result.pop('Arn')
try:
aliases = get_kms_aliases_lookup(connection)
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to obtain aliases")
result['aliases'] = aliases.get(result['KeyId'], [])
result = camel_dict_to_snake_dict(result)
# grants and tags get snakified differently
try:
result['grants'] = [camel_to_snake_grant(grant) for grant in
get_kms_grants_with_backoff(connection, key_id)['Grants']]
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to obtain key grants")
tags = get_kms_tags(connection, module, key_id)
result['tags'] = boto3_tag_list_to_ansible_dict(tags, 'TagKey', 'TagValue')
result['policies'] = get_kms_policies(connection, module, key_id)
return result
def get_kms_facts(connection, module):
try:
keys = get_kms_keys_with_backoff(connection)['Keys']
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to obtain keys")
return [get_key_details(connection, module, key['KeyId']) for key in keys]
def convert_grant_params(grant, key):
grant_params = dict(KeyId=key['key_id'],
GranteePrincipal=grant['grantee_principal'])
if grant.get('operations'):
grant_params['Operations'] = grant['operations']
if grant.get('retiring_principal'):
grant_params['RetiringPrincipal'] = grant['retiring_principal']
if grant.get('name'):
grant_params['Name'] = grant['name']
if grant.get('constraints'):
grant_params['Constraints'] = dict()
if grant['constraints'].get('encryption_context_subset'):
grant_params['Constraints']['EncryptionContextSubset'] = grant['constraints']['encryption_context_subset']
if grant['constraints'].get('encryption_context_equals'):
grant_params['Constraints']['EncryptionContextEquals'] = grant['constraints']['encryption_context_equals']
return grant_params
def different_grant(existing_grant, desired_grant):
if existing_grant.get('grantee_principal') != desired_grant.get('grantee_principal'):
return True
if existing_grant.get('retiring_principal') != desired_grant.get('retiring_principal'):
return True
if set(existing_grant.get('operations', [])) != set(desired_grant.get('operations')):
return True
if existing_grant.get('constraints') != desired_grant.get('constraints'):
return True
return False
def compare_grants(existing_grants, desired_grants, purge_grants=False):
existing_dict = dict((eg['name'], eg) for eg in existing_grants)
desired_dict = dict((dg['name'], dg) for dg in desired_grants)
to_add_keys = set(desired_dict.keys()) - set(existing_dict.keys())
if purge_grants:
to_remove_keys = set(existing_dict.keys()) - set(desired_dict.keys())
else:
to_remove_keys = set()
to_change_candidates = set(existing_dict.keys()) & set(desired_dict.keys())
for candidate in to_change_candidates:
if different_grant(existing_dict[candidate], desired_dict[candidate]):
to_add_keys.add(candidate)
to_remove_keys.add(candidate)
to_add = []
to_remove = []
for key in to_add_keys:
grant = desired_dict[key]
to_add.append(grant)
for key in to_remove_keys:
grant = existing_dict[key]
to_remove.append(grant)
return to_add, to_remove
def ensure_enabled_disabled(connection, module, key):
changed = False
if key['key_state'] == 'Disabled' and module.params['enabled']:
try:
connection.enable_key(KeyId=key['key_id'])
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to enable key")
if key['key_state'] == 'Enabled' and not module.params['enabled']:
try:
connection.disable_key(KeyId=key['key_id'])
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to disable key")
return changed
def update_key(connection, module, key):
changed = False
alias = module.params['alias']
if not alias.startswith('alias/'):
alias = 'alias/' + alias
aliases = get_kms_aliases_with_backoff(connection)['Aliases']
key_id = module.params.get('key_id')
if key_id:
# We will only add new aliases, not rename existing ones
if alias not in [_alias['AliasName'] for _alias in aliases]:
try:
connection.create_alias(KeyId=key_id, AliasName=alias)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(msg="Failed create key alias")
if key['key_state'] == 'PendingDeletion':
try:
connection.cancel_key_deletion(KeyId=key['key_id'])
# key is disabled after deletion cancellation
# set this so that ensure_enabled_disabled works correctly
key['key_state'] = 'Disabled'
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to cancel key deletion")
changed = ensure_enabled_disabled(connection, module, key) or changed
description = module.params.get('description')
# don't update description if description is not set
# (means you can't remove a description completely)
if description and key['description'] != description:
try:
connection.update_key_description(KeyId=key['key_id'], Description=description)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to update key description")
desired_tags = module.params.get('tags')
to_add, to_remove = compare_aws_tags(key['tags'], desired_tags,
module.params.get('purge_tags'))
if to_remove:
try:
connection.untag_resource(KeyId=key['key_id'], TagKeys=to_remove)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Unable to remove or update tag")
if to_add:
try:
connection.tag_resource(KeyId=key['key_id'],
Tags=[{'TagKey': tag_key, 'TagValue': desired_tags[tag_key]}
for tag_key in to_add])
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Unable to add tag to key")
desired_grants = module.params.get('grants')
existing_grants = key['grants']
to_add, to_remove = compare_grants(existing_grants, desired_grants,
module.params.get('purge_grants'))
if to_remove:
for grant in to_remove:
try:
connection.retire_grant(KeyId=key['key_arn'], GrantId=grant['grant_id'])
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Unable to retire grant")
if to_add:
for grant in to_add:
grant_params = convert_grant_params(grant, key)
try:
connection.create_grant(**grant_params)
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Unable to create grant")
# make results consistent with kms_facts
result = get_key_details(connection, module, key['key_id'])
module.exit_json(changed=changed, **camel_dict_to_snake_dict(result))
def create_key(connection, module):
params = dict(BypassPolicyLockoutSafetyCheck=False,
Tags=ansible_dict_to_boto3_tag_list(module.params['tags']),
KeyUsage='ENCRYPT_DECRYPT',
Origin='AWS_KMS')
if module.params.get('description'):
params['Description'] = module.params['description']
if module.params.get('policy'):
params['Policy'] = module.params['policy']
try:
result = connection.create_key(**params)['KeyMetadata']
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to create initial key")
key = get_key_details(connection, module, result['KeyId'])
alias = module.params['alias']
if not alias.startswith('alias/'):
alias = 'alias/' + alias
try:
connection.create_alias(AliasName=alias, TargetKeyId=key['key_id'])
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to create alias")
ensure_enabled_disabled(connection, module, key)
for grant in module.params.get('grants'):
grant_params = convert_grant_params(grant, key)
try:
connection.create_grant(**grant_params)
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to add grant to key")
# make results consistent with kms_facts
result = get_key_details(connection, module, key['key_id'])
module.exit_json(changed=True, **camel_dict_to_snake_dict(result))
def delete_key(connection, module, key):
changed = False
if key['key_state'] != 'PendingDeletion':
try:
connection.schedule_key_deletion(KeyId=key['key_id'])
changed = True
except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e:
module.fail_json_aws(e, msg="Failed to schedule key for deletion")
result = get_key_details(connection, module, key['key_id'])
module.exit_json(changed=changed, **camel_dict_to_snake_dict(result))
def get_arn_from_kms_alias(kms, aliasname):
@ -184,15 +789,18 @@ def do_grant(kms, keyarn, role_arn, granttypes, mode='grant', dry_run=True, clea
if role_arn not 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:
@ -236,43 +844,40 @@ def assert_policy_shape(policy):
def main():
argument_spec = ansible.module_utils.ec2.ec2_argument_spec()
argument_spec.update(dict(
argument_spec = 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'),
alias=dict(aliases=['key_alias']),
role_name=dict(),
role_arn=dict(),
grant_types=dict(type='list'),
clean_invalid_entries=dict(type='bool', default=True),
key_id=dict(aliases=['key_arn']),
description=dict(),
enabled=dict(type='bool', default=True),
tags=dict(type='dict', default={}),
purge_tags=dict(type='bool', default=False),
grants=dict(type='list', default=[]),
policy=dict(),
purge_grants=dict(type='bool', default=False),
state=dict(default='present', choices=['present', 'absent']),
)
)
module = AnsibleModule(
module = AnsibleAWSModule(
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']]]
required_one_of=[['alias', 'key_id']],
)
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())
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))
kms = module.client('kms')
iam = module.client('iam')
if module.params['grant_types'] or mode == 'deny':
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']:
@ -290,11 +895,31 @@ def main():
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())
module.exit_json(**result)
else:
all_keys = get_kms_facts(kms, module)
key_id = module.params.get('key_id')
alias = module.params.get('alias')
if key_id:
filtr = ('key-id', key_id)
elif module.params.get('alias'):
filtr = ('alias', alias)
candidate_keys = [key for key in all_keys if key_matches_filter(key, filtr)]
if module.params.get('state') == 'present':
if candidate_keys:
update_key(kms, module, candidate_keys[0])
else:
if module.params.get('key_id'):
module.fail_json(msg="Could not find key with id %s to update")
else:
create_key(kms, module)
else:
if candidate_keys:
delete_key(kms, module, candidate_keys[0])
else:
module.exit_json(changed=False)
if __name__ == '__main__':

View file

@ -0,0 +1,3 @@
cloud/aws
aws_kms_facts
unsupported

View file

@ -0,0 +1,3 @@
dependencies:
- prepare_tests
- setup_ec2

View file

@ -0,0 +1,394 @@
- block:
# ============================================================
- name: See whether key exists and its current state
aws_kms_facts:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
filters:
alias: "{{ resource_prefix }}-kms"
- name: create a key
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
enabled: yes
register: create_kms
- name: assert that state is enabled
assert:
that:
- create_kms.key_state == "Enabled"
- name: find facts about the key
aws_kms_facts:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
filters:
alias: "{{ resource_prefix }}-kms"
register: new_key
- name: check that a key was found
assert:
that:
- new_key["keys"]|length == 1
- name: create an IAM role that can do nothing
iam_role:
name: "{{ resource_prefix }}-kms-role"
state: present
assume_role_policy_document: '{"Version": "2012-10-17", "Statement": {"Action": "sts:AssumeRole", "Principal": {"Service": "ec2.amazonaws.com"}, "Effect": "Deny"} }'
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
register: iam_role_result
- name: grant user-style access to production secrets
aws_kms:
mode: grant
key_alias: "alias/{{ resource_prefix }}-kms"
role_name: "{{ resource_prefix }}-kms-role"
grant_types: "role,role grant"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
region: "{{ aws_region }}"
- name: find facts about the key
aws_kms_facts:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
filters:
alias: "{{ resource_prefix }}-kms"
register: new_key
- name: remove access to production secrets from role
aws_kms:
mode: deny
key_alias: "alias/{{ resource_prefix }}-kms"
role_arn: "{{ iam_role_result.iam_role.arn }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
region: "{{ aws_region }}"
- name: find facts about the key
aws_kms_facts:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
filters:
alias: "{{ resource_prefix }}-kms"
register: new_key
- fail:
- name: set aws environment base fact
set_fact:
aws_environment_base:
AWS_ACCESS_KEY_ID: "{{ aws_access_key }}"
AWS_SECRET_ACCESS_KEY: "{{ aws_secret_key }}"
no_log: True
- name: set aws environment fact
set_fact:
aws_environment: "{{ aws_environment_base|combine(security_token|ternary({'AWS_SECURITY_TOKEN': security_token}, {})) }}"
no_log: True
- name: get ARN of calling user
command: python -c 'import boto3,json; sts = boto3.client("sts"); print json.dumps(sts.get_caller_identity())'
changed_when: False
environment: "{{ aws_environment }}"
register: sts_get_caller_results
- name: set caller_arn
set_fact:
caller_arn: "{{ (sts_get_caller_results.stdout|from_json).Arn }}"
- name: Allow the IAM role to use a specific Encryption Context
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
purge_grants: yes
purge_tags: yes
grants:
- name: test_grant
grantee_principal: "{{ iam_role_result.iam_role.arn }}"
retiring_principal: "{{ caller_arn }}"
constraints:
encryption_context_equals:
environment: test
application: testapp
operations:
- Decrypt
- RetireGrant
register: grant_one
- name: assert grant added
assert:
that:
- grant_one.changed
- grant_one.grants|length == 1
- name: Add a second grant
kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
grants:
- name: another_grant
grantee_principal: "{{ iam_role_result.iam_role.arn }}"
retiring_principal: "{{ caller_arn }}"
constraints:
encryption_context_equals:
Environment: second
Application: anotherapp
operations:
- Decrypt
- RetireGrant
register: grant_two
- name: assert grant added
assert:
that:
- grant_two.changed
- grant_two.grants|length == 2
- name: Add a second grant again
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
grants:
- name: another_grant
grantee_principal: "{{ iam_role_result.iam_role.arn }}"
retiring_principal: "{{ caller_arn }}"
constraints:
encryption_context_equals:
Environment: second
Application: anotherapp
operations:
- Decrypt
- RetireGrant
register: grant_two_again
- name: assert grant added
assert:
that:
- not grant_two_again.changed
- grant_two_again.grants|length == 2
- name: Update the grants with purge_grants set
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
purge_grants: yes
grants:
- name: third_grant
grantee_principal: "{{ iam_role_result.iam_role.arn }}"
retiring_principal: "{{ caller_arn }}"
constraints:
encryption_context_equals:
environment: third
application: onemoreapp
operations:
- Decrypt
- RetireGrant
register: grant_three
- name: assert grants replaced
assert:
that:
- grant_three.changed
- grant_three.grants|length == 1
- name: update third grant to change encryption context equals to subset
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
grants:
- name: third_grant
grantee_principal: "{{ iam_role_result.iam_role.arn }}"
retiring_principal: "{{ caller_arn }}"
constraints:
encryption_context_subset:
environment: third
application: onemoreapp
operations:
- Decrypt
- RetireGrant
register: grant_three_update
- name: assert grants replaced
assert:
that:
- "grant_three_update.changed"
- "grant_three_update.grants|length == 1"
- "'encryption_context_equals' not in grant_three_update.grants[0].constraints"
- "'encryption_context_subset' in grant_three_update.grants[0].constraints"
- name: tag encryption key
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
tags:
tag_one: tag_one
tag_two: tag_two
register: tag_kms
- name: assert tags added and grants remain in place
assert:
that:
- "tag_kms.changed"
- "tag_kms.grants|length == 1"
- "'tag_one' in tag_kms.tags"
- "'tag_two' in tag_kms.tags"
- name: add, replace, remove tags
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
purge_tags: yes
tags:
tag_two: tag_two_updated
tag_three: tag_three
register: tag_kms_update
- name: assert tags correctly changed
assert:
that:
- "tag_kms_update.changed"
- "'tag_one' not in tag_kms_update.tags"
- "'tag_two' in tag_kms_update.tags"
- "tag_kms_update.tags.tag_two == 'tag_two_updated'"
- "'tag_three' in tag_kms_update.tags"
- name: make no real tag change
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
register: tag_kms_no_update
- name: assert no change to tags
assert:
that:
- "not tag_kms_no_update.changed"
- "'tag_one' not in tag_kms_no_update.tags"
- "'tag_two' in tag_kms_no_update.tags"
- "tag_kms_no_update.tags.tag_two == 'tag_two_updated'"
- "'tag_three' in tag_kms_no_update.tags"
- name: update the key's description and disable it
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
description: test key for testing
enabled: no
register: update_key
- name: assert that state is enabled
assert:
that:
- update_key.description == "test key for testing"
- update_key.key_state == "Disabled"
- update_key.changed
- name: delete the key
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: absent
register: delete_kms
- name: assert that state is pending deletion
assert:
that:
- delete_kms.key_state == "PendingDeletion"
- delete_kms.changed
- name: undelete and enable the key
aws_kms:
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
state: present
enabled: yes
register: undelete_kms
- name: assert that state is enabled
assert:
that:
- undelete_kms.key_state == "Enabled"
- undelete_kms.changed
always:
# ============================================================
- name: finish off by deleting key
aws_kms:
state: absent
region: "{{ aws_region }}"
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
alias: "{{ resource_prefix }}-kms"
register: destroy_result
- name: remove the IAM role
iam_role:
name: "{{ resource_prefix }}-kms-role"
state: absent
aws_access_key: "{{ aws_access_key }}"
aws_secret_key: "{{ aws_secret_key }}"
security_token: "{{ security_token }}"
register: iam_role_result