diff --git a/.gitignore b/.gitignore index 336766e..5737269 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ tests/output *.tar.gz galaxy.yml +release.retry # Created by https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs,dotenv # Edit at https://www.gitignore.io/?templates=git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs,dotenv diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e500b0d..eb20bd2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,26 @@ Servicenow.Servicenow Release Notes .. contents:: Topics +v1.0.5 +====== + +Major Changes +------------- + +- refactored client to inherit from AnsibleModule +- supports OpenID Connect authentication protocol +- supports bearer tokens for authentication + +Minor Changes +------------- + +- standardized invocation output + +Breaking Changes / Porting Guide +-------------------------------- + +- auth field now required for anything other than Basic authentication + v1.0.4 ====== diff --git a/README.md b/README.md index 071d4b0..a3988a0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This collection provides a series of Ansible modules, roles, and plugins for int ## Requirements - ansible version >= 2.9 - pysnow + - requests - netaddr ## Installation diff --git a/changelogs/.plugin-cache.yaml b/changelogs/.plugin-cache.yaml index fbb1bf3..792683a 100644 --- a/changelogs/.plugin-cache.yaml +++ b/changelogs/.plugin-cache.yaml @@ -22,8 +22,7 @@ plugins: name: snow_record_find namespace: '' version_added: null - netconf: {} shell: {} strategy: {} vars: {} -version: 1.0.4 +version: 1.0.5 diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index cd39a5b..85709c6 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -27,3 +27,16 @@ releases: - 34_pysnow_0.6.yml - 35_add_new_parameters.yml release_date: '2021-02-03' + 1.0.5: + changes: + breaking_changes: + - auth field now required for anything other than Basic authentication + major_changes: + - refactored client to inherit from AnsibleModule + - supports OpenID Connect authentication protocol + - supports bearer tokens for authentication + minor_changes: + - standardized invocation output + fragments: + - 27_openid.yml + release_date: '2021-04-02' diff --git a/plugins/doc_fragments/service_now.py b/plugins/doc_fragments/service_now.py index 295af3b..54c9622 100644 --- a/plugins/doc_fragments/service_now.py +++ b/plugins/doc_fragments/service_now.py @@ -10,6 +10,29 @@ class ModuleDocFragment(object): # Parameters for Service Now modules DOCUMENTATION = r''' options: + auth: + description: + - The method used to authenticate with the Service Now instance. + - Basic authentication uses user name and password. + - OAuth authentication uses a client id and secret in addition to Basic authentication. + - Token authentication uses a bearer token in addition to OAuth authentication. + - OpenID Connect authentication, an extension of OAuth 2.0, uses a provider, like Okta, to obtain a bearer token. + - If the vaule is not specified in the task, the value of environment variable C(SN_AUTH) will be used instead. + choices: ['basic', 'oauth', 'token', 'openid'] + type: str + default: basic + raise_on_empty: + description: + - If set to false, will not cause a SNOW method to raise an exception should it return no records. + - This is particurlarly useful in snow_record_find, when not sure if any record exists. + type: bool + default: True + log_level: + description: + - Set the logging level of the module + choices: ['debug', 'info', 'normal'] + type: str + default: normal instance: description: - The ServiceNow instance name, without the domain, service-now.com. @@ -26,25 +49,61 @@ class ModuleDocFragment(object): username: description: - Name of user for connection to ServiceNow. - - Required whether using Basic or OAuth authentication. + - Required whether using Basic, OAuth or OpenID authentication. - If the value is not specified in the task, the value of environment variable C(SN_USERNAME) will be used instead. required: false type: str password: description: - Password for username. - - Required whether using Basic or OAuth authentication. + - Required whether using Basic, OAuth or OpenID authentication. - If the value is not specified in the task, the value of environment variable C(SN_PASSWORD) will be used instead. required: false type: str client_id: description: - Client ID generated by ServiceNow. + - Required when using OAuth or OpenID authentication, unless token is specified. + - If the value is not specified in the task, the value of environment variable C(SN_CLIENTID) will be used instead. required: false type: str client_secret: description: - Client Secret associated with client id. + - Required when using OAuth or OpenID authentication, unless token is specified. + - If the value is not specified in the task, the value of environment variable C(SN_CLIENTSECRET) will be used instead. + required: false + type: str + token: + description: + - Bearer token associated with client id and secret. + - Can be used in place of client id and secret for OpenID authentication. + - If the value is not specified in the task, the value of environment variable C(SN_TOKEN) will be used instead. required: false type: str + openid_issuer: + description: + - The URL for your organization's OpenID Connect provider. + - Okta, an OpenID provider, supports Single Sign-On with a url like 'https://yourorg.oktapreview.com/oauth2'. + - Okta supports application-level authentication using a url like 'https://yourorg.oktapreview.com/oauth2/TH151s50m3L0ngSTr1NG'. + - If the value is not specified in the task, the value of environment variable C(OPENID_ISSUER) will be used instead. + required: false + type: str + openid_scope: + description: + - A list of scopes to be included in the access token. + - Supported scopes for this application are address, email, groups, openid, phone, profile, of which, openid must be one. + - If the value is not specified in the task, the value of environment variable C(OPENID_SCOPE) will be used instead. + required: false + type: list + elements: str + default: ['openid'] + openid: + description: + - If the result of a previous SNOW method, using OpenID, was registered, supply the C(openid) key, from the result. + - The C(openid) key contains a dictionary with the bearer token, which, if still valid, can be reused. + - If the bearer token is no longer valid, the dictionary includes all of the previously supplied C(openid_) fields needed to make a new token request. + - Any other credentials previously supplied, must be provided again. + required: false + type: dict ''' diff --git a/plugins/inventory/now.py b/plugins/inventory/now.py index b3f2897..eaa64be 100644 --- a/plugins/inventory/now.py +++ b/plugins/inventory/now.py @@ -20,7 +20,8 @@ - constructed - inventory_cache requirements: - - requests + - python requests (requests) + - netaddr options: plugin: description: The name of the ServiceNow Inventory Plugin, this should always be 'servicenow.servicenow.now'. @@ -138,7 +139,12 @@ prefix: 'tag' ''' -import netaddr +try: + import netaddr + HAS_NETADDR = True +except ImportError: + HAS_NETADDR = False + try: import requests HAS_REQUESTS = True diff --git a/plugins/module_utils/service_now.py b/plugins/module_utils/service_now.py index a3caa86..e5e5737 100644 --- a/plugins/module_utils/service_now.py +++ b/plugins/module_utils/service_now.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2019, Ansible Project +# Copyright: (c) 2019-2021, Ansible Project # Copyright: (c) 2017, Tim Rightnour # Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) from __future__ import absolute_import, division, print_function __metaclass__ = type - import traceback -from ansible.module_utils.basic import env_fallback, missing_required_lib +import logging +import time + +from ansible.module_utils.basic import AnsibleModule, env_fallback, missing_required_lib # Pull in pysnow HAS_PYSNOW = False @@ -18,82 +20,481 @@ except ImportError: PYSNOW_IMP_ERR = traceback.format_exc() +# Pull in requests +HAS_REQUESTS = False +REQUESTS_IMP_ERR = None +try: + import requests + HAS_REQUESTS = True +except ImportError: + REQUESTS_IMP_ERR = traceback.format_exc() + -class ServiceNowClient(object): - def __init__(self, module): - """ - Constructor +if HAS_REQUESTS: + class HTTPBearerAuth(requests.auth.AuthBase): + """A :class:`requests.auth.AuthBase` bearer token authentication method + per https://2.python-requests.org/en/master/user/authentication/#new-forms-of-authentication + + :param token: Bearer token to be used instead of user/pass or session """ - if not HAS_PYSNOW: - module.fail_json(msg=missing_required_lib('pysnow'), exception=PYSNOW_IMP_ERR) - - self.module = module - self.params = module.params - self.client_id = self.params['client_id'] - self.client_secret = self.params['client_secret'] - self.username = self.params['username'] - self.password = self.params['password'] - self.instance = self.params['instance'] - self.host = self.params['host'] - self.session = {'token': None} - self.conn = None - - def login(self): - result = dict( - changed=False + + def __init__(self, token): + self.token = token + + def __call__(self, r): + r.headers['Authorization'] = "Bearer {0}".format(str(self.token)) + return r +else: + class HTTPBearerAuth(object): + pass + + +class ServiceNowModule(AnsibleModule): + + def __init__(self, required_together=None, mutually_exclusive=None, required_one_of=None, *args, **kwargs): + ''' Constructor - This module mediates interactions with Service Now. + + :module: ServiceNowModule extended from AnsibleModule. + ''' + + # Initialize instance arguments + self._required_together = [ + ['username', 'password'], + ['client_id', 'client_secret'], + ] + if required_together is None: + self.required_together = self._required_together + else: + self.required_together.append(self._required_together) + + self._mutually_exclusive = [ + ['host', 'instance'], + ['openid_issuer', 'openid'], + ['openid_scope', 'openid'] + ] + if mutually_exclusive is None: + self.mutually_exclusive = self._mutually_exclusive + else: + self.mutually_exclusive.append(self._mutually_exclusive) + + self._required_one_of = [ + ['host', 'instance'], + ] + if required_one_of is None: + self.required_one_of = self._required_one_of + else: + self.required_one_of.append(self._required_one_of) + + # Initialize AnsibleModule superclass before params + super(ServiceNowModule, self).__init__( + required_together=self.required_together, + mutually_exclusive=self.mutually_exclusive, + required_one_of=self.required_one_of, + *args, + **kwargs ) - if self.params['client_id'] is not None: - try: - self.conn = pysnow.OAuthClient(client_id=self.client_id, - client_secret=self.client_secret, - token_updater=self.updater, - instance=self.instance, - host=self.host) - except Exception as detail: - self.module.fail_json(msg='Could not connect to ServiceNow: {0}'.format(str(detail)), **result) - if not self.session['token']: - # No previous token exists, Generate new. - try: - self.session['token'] = self.conn.generate_token(self.username, self.password) - except pysnow.exceptions.TokenCreateError as detail: - self.module.fail_json(msg='Unable to generate a new token: {0}'.format(str(detail)), **result) - - self.conn.set_token(self.session['token']) - elif self.username is not None: - try: - self.conn = pysnow.Client(instance=self.instance, - host=self.host, - user=self.username, - password=self.password) - except Exception as detail: - self.module.fail_json(msg='Could not connect to ServiceNow: {0}'.format(str(detail)), **result) + # Output of module + self.result = {} + + # OpenID information + self.openid = {} + self.openid['url'] = {} + + # Authenticated connection + self.connection = None + + if not HAS_PYSNOW: + AnsibleModule.fail_json(self, msg=missing_required_lib('pysnow'), + exception=PYSNOW_IMP_ERR) + if not HAS_REQUESTS: + AnsibleModule.fail_json(self, msg=missing_required_lib('requests'), + exception=REQUESTS_IMP_ERR) + + # Params + # + + # REQUIRED: Their absence will chuck a rod + # Turn on debug if not specified, but ANSIBLE_DEBUG is set + self.module_debug = {} + if self._debug: + self.warn('Enable debug output because ANSIBLE_DEBUG was set.') + self.params['log_level'] = 'debug' + self.log_level = (self.params['log_level']).lower + if self.log_level == 'debug': + # Turn on debugging + logging.basicConfig(level=logging.DEBUG) + logging.debug("Debug on for ServiceNowModule.") + + self.auth = (self.params['auth']).lower + self.raise_on_empty = self.params['raise_on_empty'] + if self.raise_on_empty: + self.raise_on_empty = None + + # OPTIONAL: Use params.get() to gracefully fail + self.instance = self.params.get('instance') + self.host = self.params.get('host') + self.username = self.params.get('username') + self.password = self.params.get('password') + self.client_id = self.params.get('client_id') + self.client_secret = self.params.get('client_secret') + self.token = self.params.get('token') + + # OpenID + if self.params.get('openid') is not None: + self.openid = self.params.get('openid') + self.token = self.openid['id_token'] + if not isinstance(self.openid['scope'], list): + self.openid['scope'] = list(self.openid['scope'].split(' ')) else: - snow_error = "Must specify username/password. Also client_id/client_secret if using OAuth." - self.module.fail_json(msg=snow_error, **result) - - def updater(self, new_token): - self.session['token'] = new_token - self.conn = pysnow.OAuthClient(client_id=self.client_id, - client_secret=self.client_secret, - token_updater=self.updater, - instance=self.instance, - host=self.host) + self.openid['iss'] = self.params.get('openid_issuer') + self.openid['scope'] = self.params.get('openid_scope') + self.openid['url']['introspect'] = "{0}/v1/introspect".format( + self.openid['iss']) + self.openid['url']['token'] = "{0}/v1/token".format( + self.openid['iss']) + self.openid['url']['userinfo'] = "{0}/v1/userinfo".format( + self.openid['iss']) + + # Log into Service Now + self._login() + + # Debugging + # + # Tools to handle debugging output from the APIs. + def _mod_debug(self, key, **kwargs): + self.module_debug[key] = kwargs + if 'module_debug' not in self.module_debug: + self.module_debug = dict(key=kwargs) + else: + self.module_debug.update(key=kwargs) + + # Login + # + # Connect using the method specified by 'auth' + def _login(self): + self.result['changed'] = False + if self.params['auth'] == 'basic': + self._auth_basic() + elif self.params['auth'] == 'oauth': + self._auth_oauth() + elif self.params['auth'] == 'token': + self._auth_token() + elif self.params['auth'] == 'openid': + self._auth_openid() + else: + self.fail( + msg="Auth method not implemented: {0}".format( + self.params['auth'] + ) + ) + + # Basic + # + # Connect using username and password + def _auth_basic(self): + try: + self.connection = pysnow.Client( + instance=self.instance, + host=self.host, + user=self.username, + password=self.password, + raise_on_empty=self.raise_on_empty + ) + except Exception as detail: + self.fail( + msg='Could not connect to ServiceNow: {0}'.format( + str(detail) + ) + ) + + # OAuth + # + # Connect using client id and secret in addition to Basic + def _auth_oauth(self): + try: + self.connection = pysnow.OAuthClient( + client_id=self.client_id, + client_secret=self.client_secret, + token_updater=self._oauth_token_updater, + instance=self.instance, + host=self.host, + raise_on_empty=self.raise_on_empty + ) + except Exception as detail: + self.fail( + msg='Could not connect to ServiceNow: {0}'.format( + str(detail) + ) + ) + if not self.token: + # No previous token exists, Generate new. + try: + self.token = self.connection.generate_token( + self.username, + self.password + ) + except pysnow.exceptions.TokenCreateError as detail: + self.fail( + msg='Unable to generate a new token: {0}'.format( + str(detail) + ) + ) + self.connection.set_token(self.token) + + def _oauth_token_updater(self, new_token): + self.token = new_token + self.connection = pysnow.OAuthClient( + client_id=self.client_id, + client_secret=self.client_secret, + token_updater=self._oauth_token_updater, + instance=self.instance, + host=self.host, + raise_on_empty=self.raise_on_empty + ) try: - self.conn.set_token(self.session['token']) + self.connection.set_token(self.token) except pysnow.exceptions.MissingToken: - snow_error = "Token is missing" - self.module.fail_json(msg=snow_error) + self.module.fail(msg="Token is missing") except Exception as detail: - self.module.fail_json(msg='Could not refresh token: {0}'.format(str(detail))) + self.module.fail( + msg='Could not refresh token: {0}'.format( + str(detail) + ) + ) + + # Token + # + # Use a supplied token instead of client id and secret. + def _auth_token(self): + try: + s = requests.Session() + s.auth = HTTPBearerAuth(self.token) + self.connection = pysnow.Client( + instance=self.instance, + host=self.host, + session=s, + raise_on_empty=self.raise_on_empty + ) + except Exception as detail: + self.fail( + msg='Could not connect to ServiceNow: {0}'.format( + str(detail) + ) + ) + + # OpenID + # + # Use the OpenID Connect protocol to obtain a bearer token. + def _auth_openid(self): + if self.openid['iss'] is None: + self.fail(msg='OpenID requires openid_issuer be specified.') + + if self.token is None: + self._openid_get_token() + else: + if 'active' not in self.openid.keys(): + self._openid_inspect_token() + if 'drift' in self.openid and self.openid['drift'] > 0: + expires = self.openid['exp'] - self.openid['drift'] + else: + expires = self.openid['exp'] + now = int(time.time()) + if not self.openid['active'] or now >= expires: + self._openid_get_token() + else: + self._openid_result() + self._auth_token() + + def _openid_get_token(self): + self.openid['iatlocal'] = int(time.time()) + r = requests.post( + self.openid['url']['token'], + auth=(self.client_id, self.client_secret), + headers={ + 'accept': 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + }, + data={ + 'grant_type': 'password', + 'username': self.username, + 'password': self.password, + 'scope': ''.join(str(e) for e in self.openid['scope']) + } + ) + self._openid_response(r) + self.token = self.openid['id_token'] + self._openid_inspect_token() + + def _openid_inspect_token(self): + r = requests.post( + self.openid['url']['introspect'], + auth=(self.client_id, self.client_secret), + headers={ + 'accept': 'application/json', + 'content-type': 'application/x-www-form-urlencoded' + }, + params={ + 'token': self.token, + 'token_type_hint': 'id_token' + } + ) + self._openid_response(r) + + def _openid_response(self, r): + r.raise_for_status() + self.openid.update(r.json()) + if 'drift' not in self.openid and 'iat' in self.openid: + self.openid['drift'] = self.openid['iat'] - self.openid['iatlocal'] + self._openid_result() + + def _openid_result(self): + if 'openid' not in self.result.keys(): + self.result['openid'] = self.openid + else: + self.result['openid'].update(self.openid) + + # + # Extend AnsibleModule methods + # + + def fail(self, msg): + if self.log_level == 'debug': + pass + AnsibleModule.fail_json(self, msg=msg, **self.result) + + def exit(self): + '''Called to end module''' + if 'invocation' not in self.result: + self.result['invocation'] = { + 'module_args': self.params, + # 'module_kwargs': { + # 'ServiceNowModuleKWArgs': self.ServiceNowModuleKWArgs, + # } + } + if self.log_level == 'debug': + if self.module_debug: + self.result['invocation'].update( + module_debug=self.module_debug) + AnsibleModule.exit_json(self, **self.result) + + def _merge_dictionaries(self, a, b): + new = a.copy() + new.update(b) + return new @staticmethod - def snow_argument_spec(): - return dict( - instance=dict(type='str', required=False, fallback=(env_fallback, ['SN_INSTANCE'])), - username=dict(type='str', required=False, fallback=(env_fallback, ['SN_USERNAME'])), - host=dict(type='str', required=False, fallback=(env_fallback, ['SN_HOST'])), - password=dict(type='str', required=False, no_log=True, fallback=(env_fallback, ['SN_PASSWORD'])), - client_id=dict(type='str', no_log=True), - client_secret=dict(type='str', no_log=True), + def create_argument_spec(): + argument_spec = dict( + auth=dict( + type='str', + choices=[ + 'basic', + 'oauth', + 'token', + 'openid', + ], + default='basic', + fallback=( + env_fallback, + ['SN_AUTH'] + ) + ), + log_level=dict( + type='str', + choices=[ + 'debug', + 'info', + 'normal', + ], + default='normal' + ), + raise_on_empty=dict( + type='bool', + default=True + ), + instance=dict( + type='str', + required=False, + fallback=( + env_fallback, + ['SN_INSTANCE'] + ) + ), + host=dict( + type='str', + required=False, + fallback=( + env_fallback, + ['SN_HOST'] + ) + ), + username=dict( + type='str', + required=False, + fallback=( + env_fallback, + ['SN_USERNAME'] + ) + ), + password=dict( + type='str', + required=False, + no_log=True, + fallback=( + env_fallback, + ['SN_PASSWORD'] + ) + ), + client_id=dict( + type='str', + required=False, + no_log=True, + fallback=( + env_fallback, + ['SN_CLIENTID'] + ) + ), + client_secret=dict( + type='str', + required=False, + no_log=True, + fallback=( + env_fallback, + ['SN_CLIENTSECRET'] + ) + ), + token=dict( + type='str', + required=False, + no_log=True, + fallback=( + env_fallback, + ['SN_TOKEN'] + ) + ), + openid=dict( + type='dict', + required=False + ), + openid_issuer=dict( + type='str', + required=False, + fallback=( + env_fallback, + ['OPENID_ISSUER'] + ) + ), + # offline_access is not supported. + openid_scope=dict( + type='list', + elements='str', + required=False, + default=['openid'], + fallback=( + env_fallback, + ['OPENID_SCOPE'] + ) + ), ) + return argument_spec diff --git a/plugins/modules/snow_record.py b/plugins/modules/snow_record.py index 6d4f7ce..3e977c8 100644 --- a/plugins/modules/snow_record.py +++ b/plugins/modules/snow_record.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# Copyright: (c) 2021, Ansible Project # Copyright: (c) 2017, Tim Rightnour # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -71,6 +72,7 @@ default: false requirements: - python pysnow (pysnow) + - python requests (requests) author: - Tim Rightnour (@garbled1) extends_documentation_fragment: @@ -89,17 +91,73 @@ table: sys_user lookup_field: sys_id +- name: Grab a user record, explicitly using basic authentication and suppress exceptions if not found + servicenow.servicenow.snow_record: + auth: basic + raise_on_empty: False + username: ansible_test + password: my_password + instance: dev99999 + state: present + number: 62826bf03710200044e0bfc8bcbe5df1 + table: sys_user + lookup_field: sys_id + - name: Grab a user record using OAuth servicenow.servicenow.snow_record: + auth: oauth + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + instance: dev99999 + state: present + number: 62826bf03710200044e0bfc8bcbe5df1 + table: sys_user + lookup_field: sys_id + +- name: Grab a user record using a bearer token + servicenow.servicenow.snow_record: + auth: token + username: ansible_test + password: my_password + token: "y0urHorrend0u51yL0ngT0kenG0esH3r3..." + instance: dev99999 + state: present + number: 62826bf03710200044e0bfc8bcbe5df1 + table: sys_user + lookup_field: sys_id + +- name: Grab a user record using OpenID + servicenow.servicenow.snow_record: + auth: openid username: ansible_test password: my_password client_id: "1234567890abcdef1234567890abcdef" client_secret: "Password1!" + openid_issuer: "https://yourorg.oktapreview.com/TH151s50meL0ngSTr1NG" + openid_scope: "openid email" instance: dev99999 state: present number: 62826bf03710200044e0bfc8bcbe5df1 table: sys_user lookup_field: sys_id + register: response + +- name: Grab another user record using previous OpenID response + servicenow.servicenow.snow_record: + auth: openid + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + openid: "{{ response['openid'] }}" + instance: dev99999 + state: present + number: 62826bf03710200044e0bfc8deadbeef + table: sys_user + lookup_field: sys_id + register: response - name: Create an incident servicenow.servicenow.snow_record: @@ -173,66 +231,74 @@ ''' import os - -from ansible.module_utils.basic import AnsibleModule +from ansible_collections.servicenow.servicenow.plugins.module_utils.service_now import ServiceNowModule from ansible.module_utils._text import to_bytes, to_native -from ansible_collections.servicenow.servicenow.plugins.module_utils.service_now import ServiceNowClient try: - # This is being handled by ServiceNowClient + # This is being handled by ServiceNowModule import pysnow + import requests except ImportError: pass -def run_module(): +def main(): # define the available arguments/parameters that a user can pass to # the module - module_args = ServiceNowClient.snow_argument_spec() + module_args = ServiceNowModule.create_argument_spec() module_args.update( - table=dict(type='str', required=False, default='incident'), - state=dict(choices=['present', 'absent'], - type='str', required=True), - number=dict(default=None, required=False, type='str'), - data=dict(default=None, required=False, type='dict'), - lookup_field=dict(default='number', required=False, type='str'), - attachment=dict(default=None, required=False, type='str'), - display_value=dict(default=False, type='bool', required=False), - exclude_reference_link=dict(default=False, type='bool', required=False), - suppress_pagination_header=dict(default=False, type='bool', required=False) + table=dict( + type='str', + default='incident' + ), + state=dict( + type='str', + required=True, + choices=[ + 'present', + 'absent' + ], + ), + number=dict( + type='str', + default=None + ), + data=dict( + type='dict', + default=None + ), + lookup_field=dict( + type='str', + default='number' + ), + attachment=dict( + type='str', + default=None + ), + display_value=dict( + type='bool', + default=False + ), + exclude_reference_link=dict( + type='bool', + default=False + ), + suppress_pagination_header=dict( + type='bool', + default=False + ) ) - module_required_together = [ - ['client_id', 'client_secret'] - ] module_required_if = [ ['state', 'absent', ['number']], ] - module_mutually_exclusive = [ - ['host', 'instance'], - ] - - module_required_one_of = [ - ['host', 'instance'], - ] - - module = AnsibleModule( + module = ServiceNowModule( argument_spec=module_args, supports_check_mode=True, - required_together=module_required_together, required_if=module_required_if, - required_one_of=module_required_one_of, - mutually_exclusive=module_mutually_exclusive, ) - # Connect to ServiceNow - service_now_client = ServiceNowClient(module) - service_now_client.login() - conn = service_now_client.conn - params = module.params - instance = params['instance'] - host = params['host'] table = params['table'] state = params['state'] number = params['number'] @@ -242,31 +308,19 @@ def run_module(): exclude_reference_link = params['exclude_reference_link'] suppress_pagination_header = params['suppress_pagination_header'] - result = dict( - changed=False, - instance=instance, - host=host, - table=table, - number=number, - lookup_field=lookup_field, - display_value=display_value, - exclude_reference_link=exclude_reference_link, - suppress_pagination_header=suppress_pagination_header - ) - # check for attachments if params['attachment'] is not None: attach = params['attachment'] b_attach = to_bytes(attach, errors='surrogate_or_strict') if not os.path.exists(b_attach): - module.fail_json(msg="Attachment {0} not found".format(attach)) - result['attachment'] = attach + module.fail(msg="Attachment {0} not found".format(attach)) + module.result['attachment'] = attach else: attach = None - conn.parameters.display_value = display_value - conn.parameters.exclude_reference_link = exclude_reference_link - conn.parameters.suppress_pagination_header = suppress_pagination_header + module.connection.parameters.display_value = display_value + module.connection.parameters.exclude_reference_link = exclude_reference_link + module.connection.parameters.suppress_pagination_header = suppress_pagination_header # Deal with check mode if module.check_mode: @@ -274,113 +328,129 @@ def run_module(): # if we are in check mode and have no number, we would have created # a record. We can only partially simulate this if number is None: - result['record'] = dict(data) - result['changed'] = True + module.result['record'] = dict(data) + module.result['changed'] = True # do we want to check if the record is non-existent? elif state == 'absent': try: - resource = conn.resource(api_path='/table/' + table) + resource = module.connection.resource( + api_path='/table/' + table) response = resource.get(query={lookup_field: number}) res = response.one() - result['record'] = dict(Success=True) - result['changed'] = True + module.result['record'] = dict(Success=True) + module.result['changed'] = True except pysnow.exceptions.NoResults: - result['record'] = None + module.result['record'] = None except Exception as detail: - module.fail_json(msg="Unknown failure in query record: {0}".format(to_native(detail)), **result) + module.fail(msg="Unknown failure in query record: {0}".format( + to_native(detail) + ) + ) # Let's simulate modification else: try: - resource = conn.resource(api_path='/table/' + table) + resource = module.connection.resource( + api_path='/table/' + table) response = resource.get(query={lookup_field: number}) res = response.one() for key, value in data.items(): res[key] = value - result['changed'] = True - result['record'] = res + module.result['changed'] = True + module.result['record'] = res except pysnow.exceptions.NoResults: - snow_error = "Record does not exist" - module.fail_json(msg=snow_error, **result) + module.fail_json(msg="Record does not exist") except Exception as detail: - module.fail_json(msg="Unknown failure in query record: {0}".format(to_native(detail)), **result) - module.exit_json(**result) + module.fail(msg="Unknown failure in query record: {0}".format( + to_native(detail) + ) + ) + module.exit() # now for the real thing: (non-check mode) # are we creating a new record? if state == 'present' and number is None: try: - resource = conn.resource(api_path='/table/' + table) + resource = module.connection.resource(api_path='/table/' + table) response = resource.create(payload=dict(data)) record = response.one() except pysnow.exceptions.UnexpectedResponseFormat as e: - snow_error = "Failed to create record: {0}, details: {1}".format(e.error_summary, e.error_details) - module.fail_json(msg=snow_error, **result) + module.fail(msg="Failed to create record: {0}, details: {1}".format( + e.error_summary, + e.error_details + ) + ) except pysnow.legacy_exceptions.UnexpectedResponse as e: - module.fail_json(msg="Failed to create record due to %s" % to_native(e), **result) - result['record'] = record - result['changed'] = True + module.fail(msg="Failed to create record due to %s" % to_native(e)) + module.result['record'] = record + module.result['changed'] = True # we are deleting a record elif state == 'absent': try: - resource = conn.resource(api_path='/table/' + table) + resource = module.connection.resource(api_path='/table/' + table) res = resource.delete(query={lookup_field: number}) except pysnow.exceptions.NoResults: res = dict(Success=True) except pysnow.exceptions.MultipleResults: - snow_error = "Multiple record match" - module.fail_json(msg=snow_error, **result) + module.fail(msg="Multiple record match") except pysnow.exceptions.UnexpectedResponseFormat as e: - snow_error = "Failed to delete record: {0}, details: {1}".format(e.error_summary, e.error_details) - module.fail_json(msg=snow_error, **result) + module.fail(msg="Failed to delete record: {0}, details: {1}".format( + e.error_summary, + e.error_details + ) + ) except pysnow.legacy_exceptions.UnexpectedResponse as e: - module.fail_json(msg="Failed to delete record due to %s" % to_native(e), **result) + module.fail(msg="Failed to delete record due to %s" % to_native(e)) except Exception as detail: - snow_error = "Failed to delete record: {0}".format(to_native(detail)) - module.fail_json(msg=snow_error, **result) - result['record'] = res - result['changed'] = True + module.fail_json(msg="Failed to delete record: {0}".format( + to_native(detail) + ) + ) + module.result['record'] = res + module.result['changed'] = True # We want to update a record else: try: - resource = conn.resource(api_path='/table/' + table) + resource = module.connection.resource(api_path='/table/' + table) response = resource.get(query={lookup_field: number}) record = response.one() if data is not None: res = response.update(data) - result['record'] = record - result['changed'] = True + record = res.one() + module.result['record'] = record + module.result['changed'] = True else: - result['record'] = record + module.result['record'] = record if attach is not None: - res = record.attach(b_attach) - result['changed'] = True - result['attached_file'] = res + res = response.upload(b_attach) + module.result['changed'] = True + module.result['attached_file'] = res except pysnow.exceptions.MultipleResults: - snow_error = "Multiple record match" - module.fail_json(msg=snow_error, **result) + module.fail(msg="Multiple record match") except pysnow.exceptions.NoResults: - snow_error = "Record does not exist" - module.fail_json(msg=snow_error, **result) + module.fail(msg="Record does not exist") except pysnow.exceptions.UnexpectedResponseFormat as e: - snow_error = "Failed to update record: {0}, details: {1}".format(e.error_summary, e.error_details) - module.fail_json(msg=snow_error, **result) + snow_error = "Failed to update record: {0}, details: {1}".format( + e.error_summary, + e.error_details + ) + module.fail(msg=snow_error) except pysnow.legacy_exceptions.UnexpectedResponse as e: - module.fail_json(msg="Failed to update record due to %s" % to_native(e), **result) + module.fail( + msg="Failed to update record due to %s" % to_native(e) + ) except Exception as detail: - snow_error = "Failed to update record: {0}".format(to_native(detail)) - module.fail_json(msg=snow_error, **result) + module.fail(msg="Failed to update record: {0}".format( + to_native(detail) + ) + ) - module.exit_json(**result) - - -def main(): - run_module() + module.exit() if __name__ == '__main__': diff --git a/plugins/modules/snow_record_find.py b/plugins/modules/snow_record_find.py index b6e79b6..ea2cae1 100644 --- a/plugins/modules/snow_record_find.py +++ b/plugins/modules/snow_record_find.py @@ -1,12 +1,14 @@ #!/usr/bin/python # -*- coding: utf-8 -*- +# Copyright: (c) 2021, Ansible Project # Copyright: (c) 2017, Tim Rightnour # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function __metaclass__ = type + DOCUMENTATION = r''' --- module: snow_record_find @@ -65,6 +67,7 @@ elements: str requirements: - python pysnow (pysnow) + - python requests (requests) author: - Tim Rightnour (@garbled1) extends_documentation_fragment: @@ -85,6 +88,20 @@ - number - opened_at +- name: Search for incident assigned to group, explicitly using basic authentication, return specific fields, and suppress exception if not found + servicenow.servicenow.snow_record_find: + auth: basic + username: ansible_test + password: my_password + instance: dev99999 + raise_on_empty: False + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + - name: Search for incident using host instead of instance servicenow.servicenow.snow_record_find: username: ansible_test @@ -99,10 +116,59 @@ - name: Using OAuth, search for incident assigned to group, return specific fields servicenow.servicenow.snow_record_find: + auth: oauth + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + instance: dev99999 + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + +- name: Using a bearer token, search for incident assigned to group, return specific fields + servicenow.servicenow.snow_record_find: + auth: token + username: ansible_test + password: my_password + token: "y0urHorrend0u51yL0ngT0kenG0esH3r3..." + instance: dev99999 + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + +- name: Using OpenID, search for incident assigned to group, return specific fields + servicenow.servicenow.snow_record_find: + auth: openid username: ansible_test password: my_password client_id: "1234567890abcdef1234567890abcdef" client_secret: "Password1!" + openid_issuer: "https://yourorg.oktapreview.com/oauth2/TH151s50M3L0ngStr1NG" + openid_scope: "openid email" + instance: dev99999 + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + register: response + +- name: Using previous OpenID response, search for incident assigned to group, return specific fields + servicenow.servicenow.snow_record_find: + auth: openid + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + openid: "{{ response['openid'] }}" instance: dev99999 table: incident query: @@ -146,13 +212,13 @@ returned: always ''' -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.servicenow.servicenow.plugins.module_utils.service_now import ServiceNowClient +from ansible_collections.servicenow.servicenow.plugins.module_utils.service_now import ServiceNowModule from ansible.module_utils._text import to_native try: - # This is being managed by ServiceNowClient + # This is being managed by ServiceNowModule import pysnow + import requests except ImportError: pass @@ -190,10 +256,11 @@ def _iterate_fields(self, data, logic_op, cond_op): for query_field, query_value in data.items(): if self.append_operator: getattr(self.qb, logic_op)() - self.condition_operator[cond_op](cond_op, query_field, query_value) + self.condition_operator[cond_op]( + cond_op, query_field, query_value) self.append_operator = True else: - self.module.fail_json(msg='Query is not in a supported format') + self.module.fail(msg='Query is not in a supported format') def _iterate_conditions(self, data, logic_op): if isinstance(data, dict): @@ -201,9 +268,14 @@ def _iterate_conditions(self, data, logic_op): if (cond_op in self.accepted_cond_ops): self._iterate_fields(fields, logic_op, cond_op) else: - self.module.fail_json(msg='Supported conditions: {0}'.format(str(self.condition_operator.keys()))) + self.module.fail( + msg='Supported conditions: {0}'.format( + str(self.condition_operator.keys()) + ) + ) else: - self.module.fail_json(msg='Supported conditions: {0}'.format(str(self.condition_operator.keys()))) + self.module.fail(msg='Supported conditions: {0}'.format( + str(self.condition_operator.keys()))) def _iterate_operators(self, data): if isinstance(data, dict): @@ -212,12 +284,17 @@ def _iterate_operators(self, data): self.simple_query = False self._iterate_conditions(cond_op, logic_op) elif self.simple_query: - self.condition_operator['equals']('equals', logic_op, cond_op) + self.condition_operator['equals']( + 'equals', logic_op, cond_op) break else: - self.module.fail_json(msg='Query is not in a supported format') + self.module.fail(msg='Query is not in a supported format') else: - self.module.fail_json(msg='Supported operators: {0}'.format(str(self.logic_operators))) + self.module.fail( + msg='Supported operators: {0}'.format( + str(self.logic_operators) + ) + ) def build_query(self): self.qb = pysnow.QueryBuilder() @@ -225,48 +302,52 @@ def build_query(self): return (self.qb) -def run_module(): +def main(): # define the available arguments/parameters that a user can pass to # the module - module_args = ServiceNowClient.snow_argument_spec() + module_args = ServiceNowModule.create_argument_spec() module_args.update( - table=dict(type='str', required=False, default='incident'), - query=dict(type='dict', required=True), - max_records=dict(default=20, type='int', required=False), - display_value=dict(default=False, type='bool', required=False), - exclude_reference_link=dict(default=False, type='bool', required=False), - suppress_pagination_header=dict(default=False, type='bool', required=False), - order_by=dict(default='-created_on', type='str', required=False), - return_fields=dict(default=[], type='list', required=False, elements='str') + table=dict( + type='str', + default='incident' + ), + query=dict( + type='dict', + required=True + ), + max_records=dict( + type='int', + default=20 + ), + display_value=dict( + type='bool', + default=False + ), + exclude_reference_link=dict( + type='bool', + default=False + ), + suppress_pagination_header=dict( + type='bool', + default=False + ), + order_by=dict( + type='str', + default='-created_on' + ), + return_fields=dict( + type='list', + elements='str', + default=[] + ) ) - module_required_together = [ - ['client_id', 'client_secret'] - ] - - module_mutually_exclusive = [ - ['host', 'instance'], - ] - module_required_one_of = [ - ['host', 'instance'], - ] - - module = AnsibleModule( + module = ServiceNowModule( argument_spec=module_args, supports_check_mode=True, - required_together=module_required_together, - required_one_of=module_required_one_of, - mutually_exclusive=module_mutually_exclusive, ) - # Connect to ServiceNow - service_now_client = ServiceNowClient(module) - service_now_client.login() - conn = service_now_client.conn - params = module.params - instance = params['instance'] - host = params['host'] table = params['table'] query = params['query'] max_records = params['max_records'] @@ -275,24 +356,11 @@ def run_module(): suppress_pagination_header = params['suppress_pagination_header'] return_fields = params['return_fields'] - result = dict( - changed=False, - instance=instance, - host=host, - table=table, - query=query, - max_records=max_records, - display_value=display_value, - exclude_reference_link=exclude_reference_link, - suppress_pagination_header=suppress_pagination_header, - return_fields=return_fields - ) - # Do the lookup try: bq = BuildQuery(module) qb = bq.build_query() - table = conn.resource(api_path='/table/' + table) + table = module.connection.resource(api_path='/table/' + table) table.parameters.display_value = display_value table.parameters.exclude_reference_link = exclude_reference_link @@ -303,15 +371,13 @@ def run_module(): limit=max_records, fields=return_fields) except Exception as detail: - module.fail_json(msg='Failed to find record: {0}'.format(to_native(detail)), **result) + module.fail( + msg='Failed to find record: {0}'.format(to_native(detail)) + ) - result['record'] = response.all() + module.result['record'] = response.all() - module.exit_json(**result) - - -def main(): - run_module() + module.exit() if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index 45188e6..cb57eb0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +requests pysnow netaddr