GCP IAM Role (#53490)

* GCP IAM Role

* module util file

* test fix

* unit tests

* test fixes

* doc fragment fixes

* test fixes

* test fix
This commit is contained in:
Alex Stephen 2019-03-13 10:28:01 -07:00
commit bef9f0e25e
7 changed files with 482 additions and 10 deletions

View file

@ -1,35 +1,49 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2018, Google Inc. # Copyright: (c) 2018, Google Inc.
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
class ModuleDocFragment(object): class ModuleDocFragment(object):
# GCP doc fragment. # GCP doc fragment.
DOCUMENTATION = ''' DOCUMENTATION = r'''
options: options:
project: project:
description: description:
- The Google Cloud Platform project to use. - The Google Cloud Platform project to use.
default: null type: str
auth_kind: auth_kind:
description: description:
- The type of credential used. - The type of credential used.
type: str
required: true required: true
choices: ["machineaccount", "serviceaccount", "application"] choices: [ application, machineaccount, serviceaccount ]
service_account_contents:
description:
- A string representing the contents of a Service Account JSON file.
- This should not be passed in as a dictionary, but a string
that has the exact contents of a service account json file (valid JSON)
type: str
service_account_file: service_account_file:
description: description:
- The path of a Service Account JSON file if serviceaccount is selected as type. - The path of a Service Account JSON file if serviceaccount is selected as type.
type: path
service_account_email: service_account_email:
description: description:
- An optional service account email address if machineaccount is selected - An optional service account email address if machineaccount is selected
and the user does not wish to use the default email. and the user does not wish to use the default email.
type: str
scopes: scopes:
description: description:
- Array of scopes to be used. - Array of scopes to be used.
type: list
notes: notes:
- For authentication, you can set service_account_file using the - For authentication, you can set service_account_file using the
C(GCP_SERVICE_ACCOUNT_FILE) env variable. C(GCP_SERVICE_ACCOUNT_FILE) env variable.
- For authentication, you can set service_account_email using the - For authentication, you can set service_account_email using the
C(GCP_SERVICE_ACCOUNT_EMAIL) env variable. C(GCP_SERVICE_ACCOUNT_EMAIL) env variable.
- For authentication, you can set service_account_contents using the
C(GCP_SERVICE_ACCOUNT_CONTENTS) env variable.
- For authentication, you can set auth_kind using the C(GCP_AUTH_KIND) env - For authentication, you can set auth_kind using the C(GCP_AUTH_KIND) env
variable. variable.
- For authentication, you can set scopes using the C(GCP_SCOPES) env variable. - For authentication, you can set scopes using the C(GCP_SCOPES) env variable.

View file

@ -21,6 +21,7 @@ from ansible.module_utils.six import string_types
from ansible.module_utils._text import to_text from ansible.module_utils._text import to_text
import ast import ast
import os import os
import json
def navigate_hash(source, path, default=None): def navigate_hash(source, path, default=None):
@ -69,7 +70,7 @@ def replace_resource_dict(item, value):
new_item = ast.literal_eval(item) new_item = ast.literal_eval(item)
return replace_resource_dict(new_item, value) return replace_resource_dict(new_item, value)
except ValueError: except ValueError:
return new_item return item
# Handles all authentication and HTTP sessions for GCP API calls. # Handles all authentication and HTTP sessions for GCP API calls.
@ -143,7 +144,8 @@ class GcpSession(object):
msg="Service Account Email only works with Machine Account-based authentication" msg="Service Account Email only works with Machine Account-based authentication"
) )
if self.module.params.get('service_account_file') is not None and self.module.params['auth_kind'] != 'serviceaccount': if (self.module.params.get('service_account_file') is not None or
self.module.params.get('service_account_contents') is not None) and self.module.params['auth_kind'] != 'serviceaccount':
self.module.fail_json( self.module.fail_json(
msg="Service Account File only works with Service Account-based authentication" msg="Service Account File only works with Service Account-based authentication"
) )
@ -153,9 +155,12 @@ class GcpSession(object):
if cred_type == 'application': if cred_type == 'application':
credentials, project_id = google.auth.default(scopes=self.module.params['scopes']) credentials, project_id = google.auth.default(scopes=self.module.params['scopes'])
return credentials return credentials
elif cred_type == 'serviceaccount': elif cred_type == 'serviceaccount' and self.module.params.get('service_account_file'):
path = os.path.realpath(os.path.expanduser(self.module.params['service_account_file'])) path = os.path.realpath(os.path.expanduser(self.module.params['service_account_file']))
return service_account.Credentials.from_service_account_file(path).with_scopes(self.module.params['scopes']) return service_account.Credentials.from_service_account_file(path).with_scopes(self.module.params['scopes'])
elif cred_type == 'serviceaccount' and self.module.params.get('service_account_contents'):
cred = json.loads(self.module.params.get('service_account_contents'))
return service_account.Credentials.from_service_account_info(cred).with_scopes(self.module.params['scopes'])
elif cred_type == 'machineaccount': elif cred_type == 'machineaccount':
return google.auth.compute_engine.Credentials( return google.auth.compute_engine.Credentials(
self.module.params['service_account_email']) self.module.params['service_account_email'])
@ -199,6 +204,10 @@ class GcpModule(AnsibleModule):
required=False, required=False,
fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_FILE']), fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_FILE']),
type='path'), type='path'),
service_account_contents=dict(
required=False,
fallback=(env_fallback, ['GCP_SERVICE_ACCOUNT_CONTENTS']),
type='str'),
scopes=dict( scopes=dict(
required=False, required=False,
fallback=(env_fallback, ['GCP_SCOPES']), fallback=(env_fallback, ['GCP_SCOPES']),
@ -211,7 +220,7 @@ class GcpModule(AnsibleModule):
mutual = kwargs['mutually_exclusive'] mutual = kwargs['mutually_exclusive']
kwargs['mutually_exclusive'] = mutual.append( kwargs['mutually_exclusive'] = mutual.append(
['service_account_email', 'service_account_file'] ['service_account_email', 'service_account_file', 'service_account_contents']
) )
AnsibleModule.__init__(self, *args, **kwargs) AnsibleModule.__init__(self, *args, **kwargs)

View file

@ -0,0 +1,316 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Copyright (C) 2017 Google
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# ----------------------------------------------------------------------------
#
# *** AUTO GENERATED CODE *** AUTO GENERATED CODE ***
#
# ----------------------------------------------------------------------------
#
# This file is automatically generated by Magic Modules and manual
# changes will be clobbered when the file is regenerated.
#
# Please read more about how to change this file at
# https://www.github.com/GoogleCloudPlatform/magic-modules
#
# ----------------------------------------------------------------------------
from __future__ import absolute_import, division, print_function
__metaclass__ = type
################################################################################
# Documentation
################################################################################
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ["preview"], 'supported_by': 'community'}
DOCUMENTATION = '''
---
module: gcp_iam_role
description:
- A role in the Identity and Access Management API .
short_description: Creates a GCP Role
version_added: 2.8
author: Google Inc. (@googlecloudplatform)
requirements:
- python >= 2.6
- requests >= 2.18.4
- google-auth >= 1.3.0
options:
state:
description:
- Whether the given object should exist in GCP
choices:
- present
- absent
default: present
name:
description:
- The name of the role.
required: true
title:
description:
- A human-readable title for the role. Typically this is limited to 100 UTF-8
bytes.
required: false
description:
description:
- Human-readable description for the role.
required: false
included_permissions:
description:
- Names of permissions this role grants when bound in an IAM policy.
required: false
stage:
description:
- The current launch stage of the role.
required: false
choices:
- ALPHA
- BETA
- GA
- DEPRECATED
- DISABLED
- EAP
extends_documentation_fragment: gcp
'''
EXAMPLES = '''
- name: create a role
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: test_project
auth_kind: serviceaccount
service_account_file: "/tmp/auth.pem"
state: present
'''
RETURN = '''
name:
description:
- The name of the role.
returned: success
type: str
title:
description:
- A human-readable title for the role. Typically this is limited to 100 UTF-8 bytes.
returned: success
type: str
description:
description:
- Human-readable description for the role.
returned: success
type: str
includedPermissions:
description:
- Names of permissions this role grants when bound in an IAM policy.
returned: success
type: list
stage:
description:
- The current launch stage of the role.
returned: success
type: str
deleted:
description:
- The current deleted state of the role.
returned: success
type: bool
'''
################################################################################
# Imports
################################################################################
from ansible.module_utils.gcp_utils import navigate_hash, GcpSession, GcpModule, GcpRequest, replace_resource_dict
import json
################################################################################
# Main
################################################################################
def main():
"""Main function"""
module = GcpModule(
argument_spec=dict(
state=dict(default='present', choices=['present', 'absent'], type='str'),
name=dict(required=True, type='str'),
title=dict(type='str'),
description=dict(type='str'),
included_permissions=dict(type='list', elements='str'),
stage=dict(type='str', choices=['ALPHA', 'BETA', 'GA', 'DEPRECATED', 'DISABLED', 'EAP']),
)
)
if not module.params['scopes']:
module.params['scopes'] = ['https://www.googleapis.com/auth/iam']
state = module.params['state']
fetch = fetch_resource(module, self_link(module))
changed = False
if fetch:
if state == 'present':
if is_different(module, fetch):
update(module, self_link(module), fetch)
fetch = fetch_resource(module, self_link(module))
changed = True
else:
delete(module, self_link(module))
fetch = {}
changed = True
else:
if state == 'present':
fetch = create(module, collection(module))
changed = True
else:
fetch = {}
fetch.update({'changed': changed})
module.exit_json(**fetch)
def create(module, link):
auth = GcpSession(module, 'iam')
return return_if_object(module, auth.post(link, resource_to_create(module)))
def update(module, link, fetch):
auth = GcpSession(module, 'iam')
params = {'updateMask': updateMask(resource_to_request(module), response_to_hash(module, fetch))}
request = resource_to_request(module)
del request['name']
return return_if_object(module, auth.put(link, request, params=params))
def updateMask(request, response):
update_mask = []
if request.get('name') != response.get('name'):
update_mask.append('name')
if request.get('title') != response.get('title'):
update_mask.append('title')
if request.get('description') != response.get('description'):
update_mask.append('description')
if request.get('includedPermissions') != response.get('includedPermissions'):
update_mask.append('includedPermissions')
if request.get('stage') != response.get('stage'):
update_mask.append('stage')
return ','.join(update_mask)
def delete(module, link):
auth = GcpSession(module, 'iam')
return return_if_object(module, auth.delete(link))
def resource_to_request(module):
request = {
u'name': module.params.get('name'),
u'title': module.params.get('title'),
u'description': module.params.get('description'),
u'includedPermissions': module.params.get('included_permissions'),
u'stage': module.params.get('stage'),
}
return_vals = {}
for k, v in request.items():
if v or v is False:
return_vals[k] = v
return return_vals
def fetch_resource(module, link, allow_not_found=True):
auth = GcpSession(module, 'iam')
return return_if_object(module, auth.get(link), allow_not_found)
def self_link(module):
return "https://iam.googleapis.com/v1/projects/{project}/roles/{name}".format(**module.params)
def collection(module):
return "https://iam.googleapis.com/v1/projects/{project}/roles".format(**module.params)
def return_if_object(module, response, allow_not_found=False):
# If not found, return nothing.
if allow_not_found and response.status_code == 404:
return None
# If no content, return nothing.
if response.status_code == 204:
return None
try:
module.raise_for_status(response)
result = response.json()
except getattr(json.decoder, 'JSONDecodeError', ValueError):
module.fail_json(msg="Invalid JSON response with error: %s" % response.text)
result = decode_response(result, module)
if navigate_hash(result, ['error', 'errors']):
module.fail_json(msg=navigate_hash(result, ['error', 'errors']))
return result
def is_different(module, response):
request = resource_to_request(module)
response = response_to_hash(module, response)
request = decode_response(request, module)
# Remove all output-only from response.
response_vals = {}
for k, v in response.items():
if k in request:
response_vals[k] = v
request_vals = {}
for k, v in request.items():
if k in response:
request_vals[k] = v
return GcpRequest(request_vals) != GcpRequest(response_vals)
# Remove unnecessary properties from the response.
# This is for doing comparisons with Ansible's current parameters.
def response_to_hash(module, response):
return {
u'name': response.get(u'name'),
u'title': response.get(u'title'),
u'description': response.get(u'description'),
u'includedPermissions': response.get(u'includedPermissions'),
u'stage': response.get(u'stage'),
u'deleted': response.get(u'deleted'),
}
def resource_to_create(module):
role = resource_to_request(module)
del role['name']
return {'roleId': module.params['name'], 'role': role}
def decode_response(response, module):
if 'name' in response:
response['name'] = response['name'].split('/')[-1]
return response
if __name__ == '__main__':
main()

View file

@ -0,0 +1,2 @@
cloud/gcp
unsupported

View file

@ -0,0 +1,3 @@
---
# defaults file
resource_name: '{{resource_prefix}}'

View file

@ -0,0 +1,128 @@
---
# ----------------------------------------------------------------------------
#
# *** AUTO GENERATED CODE *** AUTO GENERATED CODE ***
#
# ----------------------------------------------------------------------------
#
# This file is automatically generated by Magic Modules and manual
# changes will be clobbered when the file is regenerated.
#
# Please read more about how to change this file at
# https://www.github.com/GoogleCloudPlatform/magic-modules
#
# ----------------------------------------------------------------------------
# Pre-test setup
- name: delete a role
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
state: absent
#----------------------------------------------------------
- name: create a role
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
state: present
register: result
- name: assert changed is true
assert:
that:
- result.changed == true
- name: verify that role was created
gcp_iam_role_facts:
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
scopes:
- https://www.googleapis.com/auth/iam
register: results
- name: verify that command succeeded
assert:
that:
- results['items'] | length >= 1
# ----------------------------------------------------------------------------
- name: create a role that already exists
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
state: present
register: result
- name: assert changed is false
assert:
that:
- result.changed == false
#----------------------------------------------------------
- name: delete a role
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
state: absent
register: result
- name: assert changed is true
assert:
that:
- result.changed == true
- name: verify that role was deleted
gcp_iam_role_facts:
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
scopes:
- https://www.googleapis.com/auth/iam
register: results
- name: verify that command succeeded
assert:
that:
- results['items'] | length == 0
# ----------------------------------------------------------------------------
- name: delete a role that does not exist
gcp_iam_role:
name: myCustomRole2
title: My Custom Role
description: My custom role description
included_permissions:
- iam.roles.list
- iam.roles.create
- iam.roles.delete
project: "{{ gcp_project }}"
auth_kind: "{{ gcp_cred_kind }}"
service_account_file: "{{ gcp_cred_file }}"
state: absent
register: result
- name: assert changed is false
assert:
that:
- result.changed == false