google.cloud/plugins/modules/gcp_secret_manager.py

457 lines
15 KiB
Python

#!/usr/bin/python
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt
# or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
################################################################################
# Documentation
################################################################################
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ["preview"], 'supported_by': 'community'}
DOCUMENTATION = '''
---
module: gcp_secret_manager
description:
- Access secrets stored in Google Secrets Manager.
- Create new secrets.
- Create new secret values.
- Add/remove versions of secrets.
- Please note that other features like etags, replication, annontation expected to be managed outside of Ansible.
short_description: Access and Update Google Cloud Secrets Manager objects
author: Dave Costakos @RedHat
requirements:
- python >= 2.6
- requests >= 2.18.4
- google-auth >= 1.3.0
options:
project:
description:
- The Google Cloud Platform project to use.
type: str
auth_kind:
description:
- The type of credential used.
type: str
required: true
choices:
- application
- machineaccount
- serviceaccount
service_account_contents:
description:
- The contents of a Service Account JSON file, either in a dictionary or as a
JSON string that represents it.
type: jsonarg
service_account_file:
description:
- The path of a Service Account JSON file if serviceaccount is selected as type.
type: path
service_account_email:
description:
- An optional service account email address if machineaccount is selected and
the user does not wish to use the default email.
type: str
scopes:
description:
- Array of scopes to be used
type: list
elements: str
name:
description:
- Name of the secret to be used
type: str
required: true
aliases:
- key
- secret
- secret_id
value:
description:
- The secret value that the secret should have
- this will be set upon create
- If the secret value is not this, a new version will be added with this value
type: str
state:
description:
- whether the secret should exist
default: present
choices:
- absent
- present
type: str
return_value:
description:
- if true, the value of the secret will be returned unencrypted to Ansible
- if false, no value will be returned or decrypted
type: bool
default: true
version:
description:
- A version label to apply to the secret
- Default is "latest" which is the newest version of the secret
- The special "all" is also acceptable on delete (which will delete all versions of a secret)
type: str
default: latest
labels:
description:
- A set of key-value pairs to assign as labels to asecret
- only used in creation
- Note that the "value" piece of a label must contain only readable chars
type: dict
notes:
- 'API Reference: U(https://cloud.google.com/secret-manager/docs/reference/rests)'
- 'Official Documentation: U(https://cloud.google.com/secret-manager/docs/overview)'
- for authentication, you can set service_account_file using the C(GCP_SERVICE_ACCOUNT_FILE)
env variable.
- for authentication, you can set service_account_contents using the C(GCP_SERVICE_ACCOUNT_CONTENTS)
env variable.
- For authentication, you can set service_account_email using the C(GCP_SERVICE_ACCOUNT_EMAIL)
env variable.
- For authentication, you can set auth_kind using the C(GCP_AUTH_KIND) env variable.
- For authentication, you can set scopes using the C(GCP_SCOPES) env variable.
- Environment variables values will only be used if the playbook values are not set.
- The I(service_account_email) and I(service_account_file) options are mutually exclusive.
'''
EXAMPLES = r'''
- name: Create a new secret
google.cloud.gcp_secret_manager:
name: secret_key
value: super_secret
state: present
auth_kind: serviceaccount
service_account_file: service_account_creds.json
- name: Ensure the secretexists, fail otherwise and return the value
google.cloud.gcp_secret_manager:
name: secret_key
state: present
- name: Ensure secret exists but don't return the value
google.cloud.gcp_secret_manager:
name: secret_key
state: present
return_value: false
- name: Add a new version of a secret
google.cloud.gcp_secret_manager:
name: secret_key
value: updated super secret
state: present
- name: Delete version 1 of a secret (but not the secret itself)
google.cloud.gcp_secret_manager:
name: secret_key
version: 1
state: absent
- name: Delete all versions of a secret
google.cloud.gcp_secret_manager:
name: secret_key
version: all
state: absent
- name: Create a secret with labels
google.cloud.gcp_secret_manager:
name: secret_key
value: super_secret
labels:
key_name: "ansible_rox"
'''
RETURN = r'''
resources:
description: List of resources
returned: always
type: complex
name:
description:
- The name of the secret
returned: success
type: str
version:
description:
- the version number of the secret returned
returned: success
type: str
url:
description:
- the Google Cloud URL used to make the request
returned: success
type: str
status_code:
description:
- the HTTP status code of the response to Google Cloud
returned: success
type: str
msg:
description:
- A message indicating what was done (or not done)
returned: success, failure
type: str
value:
description:
- The decrypted secret value, please use care with this
returned: success
type: str
payload:
description:
- The base 64 secret payload including CRC for validation
retunred: success
type: dict
'''
################################################################################
# Imports
################################################################################
from ansible_collections.google.cloud.plugins.module_utils.gcp_utils import (
navigate_hash,
GcpSession,
GcpModule
)
# for decoding and validating secrets
import json
import base64
import copy
def get_auth(module):
return GcpSession(module, 'secret-manager')
def self_access_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}:access".format(**module.params)
def self_get_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version}".format(**module.params)
def self_update_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions/{calc_version:version}".format(**module.params)
def self_list_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}/versions?filter=state:ENABLED".format(**module.params)
def self_delete_link(module):
return "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}".format(**module.params)
def fetch_resource(module, allow_not_found=True):
auth = get_auth(module)
# set version to the latest version because
# we can't be sure that "latest" is always going
# to be set if secret versions get disabled
# see https://issuetracker.google.com/issues/286489671
if module.params['version'] == "latest" or module.params['version'] == 'all':
version_list = list_secret_versions(module)
latest_version = None
if version_list is None:
return None
if "versions" in version_list:
versions_numbers = []
for version in version_list['versions']:
versions_numbers.append(version['name'].split('/')[-1])
latest_version = sorted(versions_numbers, key=int)[-1]
module.params['calc_version'] = latest_version
else:
# if this occurs, there are no available secret versions
# handle the corner case that we tried to delete
# a secret version that doesn't exist
if module.params['state'] == "absent":
return {"action": "delete_secret"}
link = self_access_link(module)
access_obj = return_if_object(module, auth.get(link), allow_not_found)
if access_obj is None:
return None
link = self_get_link(module)
get_obj = return_if_object(module, auth.get(link), allow_not_found)
if get_obj is None:
return None
return merge_dicts(get_obj, access_obj)
def merge_dicts(x, y):
z = copy.deepcopy(x)
z.update(y)
return z
# create secret is a create call + an add version call
def create_secret(module):
# build the payload
payload = {"replication": {"automatic": {}}}
if module.params['labels']:
payload['labels'] = module.params['labels']
url = "https://secretmanager.googleapis.com/v1/projects/{project}/secrets".format(**module.params)
auth = get_auth(module)
post_response = auth.post(url, body=payload, params={'secretId': module.params['name']})
# validate create
module.raise_for_status(post_response)
return update_secret(module)
def update_secret(module):
# build the payload
b64_value = base64.b64encode(module.params['value'].encode("utf-8")).decode("utf-8")
payload = {
u'payload': {
u'data': b64_value
}
}
auth = get_auth(module)
url = "https://secretmanager.googleapis.com/v1/projects/{project}/secrets/{name}:addVersion".format(**module.params)
return return_if_object(module, auth.post(url, payload), False)
def list_secret_versions(module):
# filter by only enabled secrets
url = self_list_link(module)
auth = get_auth(module)
return return_if_object(module, auth.get(url), True)
# technically we're destroying the version
def delete_secret(module, destroy_all=False):
# delete secret does not take "latest" as a default version
# get the latest version if it doesn't exist in the request
version = module.params['version']
auth = get_auth(module)
if version.lower() == "all" or destroy_all:
url = self_delete_link(module)
return return_if_object(module, auth.delete(url))
else:
url = self_get_link(module) + ":destroy"
return return_if_object(module, auth.post(url, {}), False)
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 response.status_code == 409:
module.params['info'] = "exists already"
return None
# probably a code error
if response.status_code == 400:
module.fail_json(msg="unexpected REST failure: %s" % response.json()['error'])
# If no content, return nothing.
if response.status_code == 204:
return None
try:
module.raise_for_status(response)
result = response.json()
result['url'] = response.request.url
result['status_code'] = response.status_code
if "name" in result:
result['version'] = result['name'].split("/")[-1]
result['name'] = result['name'].split("/")[3]
# base64 decode the value
if "payload" in result and "data" in result['payload']:
result['value'] = base64.b64decode(result['payload']['data']).decode("utf-8")
except getattr(json.decoder, 'JSONDecodeError', ValueError):
module.fail_json(msg="Invalid JSON response with error: %s" % response.text)
if navigate_hash(result, ['error', 'errors']):
module.fail_json(msg=navigate_hash(result, ['error', 'errors']))
return result
def main():
# limited support for parameters described in the "Secret" resource
# in order to simplify and deploy primary use cases
# expectation is customers needing to support additional capabilities
# in the SecretPayload will do so outside of Ansible.
# ref: https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets#Secret
module = GcpModule(
argument_spec=dict(
state=dict(default='present', choices=['present', 'absent'], type='str'),
name=dict(required=True, type='str', aliases=['key', 'secret', 'secret_id']),
value=dict(required=False, type='str'),
version=dict(required=False, type='str', default='latest'),
return_value=dict(required=False, type='bool', default=True),
labels=dict(required=False, type='dict', default=dict())
)
)
if not module.params['scopes']:
module.params['scopes'] = ["https://www.googleapis.com/auth/cloud-platform"]
module.params['calc_version'] = module.params['version']
state = module.params['state']
fetch = fetch_resource(module, allow_not_found=True)
changed = False
# nothing came back, so the secret doesn't exist
if not fetch:
# doesn't exist, must create
if module.params.get('value') and state == 'present':
# create a new secret
fetch = create_secret(module)
changed = True
# specified present but no value
# fail, let the user know
# that no secret could be created without a value to encrypt
elif state == 'present':
module.fail_json(msg="secret '{name}' not present in '{project}' and no value for the secret is provided".format(**module.params))
# secret is absent, success
else:
fetch = {"msg": "secret '{name}' in project '{project}' not present".format(**module.params)}
else:
# delete the secret version (latest if no version is specified)
if state == "absent":
# delete the secret
fetch = delete_secret(module, ("action" in fetch))
fetch['msg'] = "Secret Destroyed, it may take time to propagate"
changed = True
# check to see if the values are the same, and update if neede
if "value" in fetch and module.params.get('value') is not None:
# Update secret
if fetch['value'] != module.params['value']:
update_secret(module)
changed = True
else:
fetch['msg'] = "values identical, no need to update secret"
# pop value data if return_value == false
if module.params['return_value'] is False:
fetch.pop('value')
fetch.pop('payload')
if "msg" in fetch:
fetch['msg'] = "{} | not returning secret value since 'return_value is set to false".format(fetch['msg'])
else:
fetch['msg'] = "not returning secret value since 'return_value is set to false"
fetch['changed'] = changed
fetch['name'] = module.params['name']
module.exit_json(**fetch)
if __name__ == "__main__":
main()