You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SH-Deployer/apps/SA-ITOA/lib/ITOA/rest_interface_provider_bas...

407 lines
15 KiB

# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
import sys
import json
from io import StringIO
from splunk import RESTException
from splunk.clilib.bundle_paths import make_splunkhome_path
sys.path.append(make_splunkhome_path(['etc', 'apps', 'SA-ITOA', 'lib']))
import itsi_path
import itsi_py3
from ITOA.storage import itoa_storage
from ITOA.storage.statestore import StateStoreError
from ITOA.itoa_config import get_collection_name_for_itoa_object
from ITOA.itoa_exceptions import ItoaError
from ITOA.setup_logging import logger, InstrumentCall
from ITOA.controller_utils import ITOAError, ItoaValidationError, itoa_response_headers
from migration.supervisor import MigrationSupervisor
from user_access_errors import UserAccessError
logger.debug("Initialized itoa interface provider log")
class ItoaInterfaceProviderBase(object):
"""
Base provider implementing services for REST APIs
It primarily consists of CRUD/bulk actions to configure and use basic ITSI objects like entities, services, etc.
Specific REST handlers derive from this class to fit functionality to specific REST handling
"""
def __init__(self):
"""
Basic constructor
@type: object
@param self: The self reference
"""
super(ItoaInterfaceProviderBase, self).__init__()
self._session_key = None
self._current_user = None
self._rest_method = None
self._instrumentation = None
def _setup(self, session_key, current_user, rest_method, loggero=None):
"""
Method to setup provider before handler from the provider are invoked
@type: string
@param session_key: session key to splunkd
@type: string
@param current_user: current user initiating REST call
@type: string
@param rest_method: REST method initiated, GET/POST/PUT/DELETE
@type loggero: logger
@param loggero: caller's logger
@return: None
"""
self._session_key = session_key if isinstance(
session_key, itsi_py3.string_type) else None
self._current_user = current_user if isinstance(
current_user, itsi_py3.string_type) else None
self._rest_method = rest_method.upper() if isinstance(
rest_method, itsi_py3.string_type) else None
self._instrumentation = InstrumentCall(
loggero) if loggero else InstrumentCall(logger)
def render_json(self, json_response):
"""
given data, convert it to a JSON which is consumable by a web client
@type: json
@param json_response: the response to render for REST
@rtype: string
@return: normalized JSON as a string
"""
try:
response = json.dumps(json_response).replace("</", "<\\/")
except (ValueError, TypeError) as e:
logger.exception(e)
response = str(json_response).replace("</", "<\\/")
# Pad with 256 bytes of whitespace for IE security issue. See SPL-34355
return ' ' * 256 + '\n' + response
def _get_storage_interface(self, object_type=None):
"""
Method to obtain a storage interface object for a given object type to work on
@type: ItoaInterfaceProviderBase
@param self: The self reference
@type: string
@param object_type: ITOA Object type (service/entity/kpi/glass_table etc...)
@rtype: ITOAStorage
@return storage_interface: itoa_storage instance initialized to appropriate collection
"""
collection = get_collection_name_for_itoa_object(object_type)
logger.debug('Collection name for object type "%s" is "%s"',
object_type, collection)
init_params = {}
if collection:
init_params['collection'] = collection
logger.debug('Initializing itoa_storage obj with "%s"', init_params)
return itoa_storage.ITOAStorage(**init_params)
def _validate_field(self, field):
"""
Quick and dirty validation that we have a non-None field,
Here as a method because I was doing it everywhere
@type: object
@param self: The self reference
@type: string
@param field: The field to be tested
@rtype: string
@return: The validated field
"""
if field is None:
message = "Missing field"
logger.error(message)
raise ITOAError(status="400", message=message)
return field
class SplunkdRestInterfaceBase(object):
"""
Class implementation for REST handler providing services for maintenance services interface endpoints.
Meant for use with a persisted non-EAI rest handler deriving from PersistentServerConnectionApplication
"""
def __init__(self):
"""
Basic constructor
"""
super(SplunkdRestInterfaceBase, self).__init__()
def migration_check(self, session_key):
migration_checker = MigrationSupervisor(session_key)
if migration_checker.is_migration_running():
raise ITOAError(
status=503,
message='ITSI application migration is in progress. API requests processing has been paused until after migration is done.'
)
def handle_payload_error(self, e, message):
response_payload = {'message': message}
for attr in ('uid', 'context'):
if hasattr(e, attr) and getattr(e, attr) != '':
response_payload[attr] = getattr(e, attr)
return response_payload
def _default_handle(self, args):
"""
Blanket handler for all REST calls on the interface routing the GET/POST/PUT/DELETE requests.
Derived implementation from PersistentServerConnectionApplication.
This is a generic implementation that specific derived implementation could use optionally
@type args: json
@param args: a JSON string representing a dictionary of arguments to the REST call.
@rtype: json
@return: a valid REST response
"""
logger.cleanUpContext()
logger.debug('Splunkd REST handler for ITOA interface received request with args: %s', args)
response_status = 500
response_payload = []
try:
args = json.loads(args)
self.migration_check(args['session']['authtoken'])
result = self._dispatch_to_provider(args)
if result is None or isinstance(result, itsi_py3.string_type) or isinstance(result, FileDownload):
rest_method = args['method']
response_status = 200
if rest_method == 'DELETE':
response_status = 204
response_payload = result
else:
response_status = 500
response_payload = {'message': 'Received unexpected results from dispatcher: {}'.format(result)}
except (ITOAError, UserAccessError) as e:
logger.exception(e)
response_status = e.status
response_payload = self.handle_payload_error(e, e._message)
except RESTException as e:
logger.exception(e)
response_status = e.statusCode
response_payload = self.handle_payload_error(e, str(e))
except StateStoreError as e:
response_status = e.status_code or 500
response_payload = self.handle_payload_error(e, str(e))
except ItoaError as e:
response_status = e.status_code or 500
response_payload = self.handle_payload_error(e, str(e))
except Exception as e:
logger.exception(e)
response_status = 500
response_payload = self.handle_payload_error(e, str(e))
try:
response_status = int(response_status)
except (ValueError, TypeError):
response_status = 500
# for serving files, splunkd needs the payload_base64 & filename fields. It will decode and send binary data to client
if isinstance(response_payload, FileDownload):
header_type = {
'file_download': 'zip'
}
file_download_headers = itoa_response_headers(header_type)
response = {
'filename': response_payload.filename,
'payload_base64': response_payload.content.decode(),
'headers': file_download_headers,
}
else: # all other responses
headers = itoa_response_headers(args)
response = {
'status': response_status,
'payload': response_payload,
'headers': headers,
}
return response
def _dispatch_to_provider(self, args):
"""
Parses the REST path on the interface to help route to specific providers in derived overrides
@type: dict
@param args: the args routed for the REST method
@rtype: dict
@return: results of the REST method
"""
raise NotImplementedError
@staticmethod
def extract_request_owner(args, rest_method_args):
"""
Helper method to identify owner specified in request
Owner is the user specified in namespace for the splunkd url like /servicesNS/<owner>/<app>
Extract this owner or default to current user as owner
@type: dict
@param args: args provided by splunkd server to handler for REST request
@rtype: basestring
@return: the owner identified
"""
owner = args['session']['user']
if 'ns' in args and 'user' in args['ns']:
owner = args['ns']['user']
# Clean up the owner from the REST method args to enforce/reflect namespace owner only applies
if 'owner' in rest_method_args:
del rest_method_args['owner']
return owner
@staticmethod
def extract_rest_args(args, args_field, args_dict):
"""
Helper method to extract dict form of a given field's value from splunkd server provided args to a handler
@type: dict
@param args: args provided by splunkd server to handler for REST request
@type: string
@param args_field: field to extract as dict. This field's value MUST be a list in args
@type: dict
@param args_dict: the in/out of this method which is a dict to which args from the field are appended/overwritten
@rtype: None
@return: None
"""
if (not (
isinstance(args, dict)
and isinstance(args_field, itsi_py3.string_type)
and isinstance(args_dict, dict)
and isinstance(args.get(args_field, []), list)
)):
raise ITOAError(
'Invalid args received by extract_rest_args. args: {}, field: {}'.format(args, args_field))
for term in args.get(args_field, []):
if len(term) == 2:
# term[0] is arg name and term[1] is value
args_dict[term[0]] = term[1]
@staticmethod
def extract_force_delete_header(args, args_dict):
"""
Helper method to extract the 'X-Force-Delete' header from splunkd provided args to a handler
@type: dict
@param args: args provided by splunkd server to handler for REST request
@type: dict
@param args_dict: the in/out of this method which is a dict to which args from the field are appended/overwritten
@rtype: None
@return: None
"""
# Looking for a specific key
key = 'X-Force-Delete'
if args.get('headers') is not None and isinstance(args.get('headers'), list):
for array in args['headers']:
for term in array:
if term == key:
if len(array) == 2 and array[0] == key:
args_dict[array[0]] = array[1]
@staticmethod
def extract_data_payload(args):
"""
Custom fit method that extracts "data" from payload in a specific way that may work only for
some splunkd REST handlers.
@type: dict
@param args: args provided by splunkd server to handler for REST request
@rtype: json
@return: data payload extracted from the REST args
"""
form_data = {}
# Note that form data being present here implies head specified content type correctly
data = None
# Allow both raw payload (Content-Type application/json) and form payload
# (Content-Type application/x-www-form-urlencoded). Will skip checking headers here
# so as to allow current client API sets to continue without incurring changes/convenience
# for client API. If needed, we could add specific header checks in future.
is_use_form = True
if 'payload' in args and len(args['payload']) > 0:
# if this is a csv file upload, we use a csv-specific method for extraction
if 'name=\"csvfile\"' in args['payload']:
return SplunkdRestInterfaceBase._extract_csv_payload(args['payload'])
# all other cases below
try:
form_data = json.loads(args['payload'])
is_use_form = False
except (ValueError, TypeError):
logger.warning("Invalid JSON in payload found, make sure the form data is valid.")
# Ignore payload contents since it isnt valid JSON, lets try form data
is_use_form = True
# if the handler has received a base64 payload, assume it is a generic file upload
if 'payload_base64' in args and len(args['payload_base64']) > 0:
return {'uploadedFileBase64Content': StringIO(args['payload_base64'])}
if is_use_form:
SplunkdRestInterfaceBase.extract_rest_args(args, 'form', form_data)
if 'data' in form_data:
data = form_data['data']
else:
data = form_data
form_data = {}
try:
if isinstance(data, itsi_py3.string_type):
form_data['data'] = json.loads(data)
elif isinstance(data, dict) or isinstance(data, list):
form_data['data'] = data
except (ValueError, TypeError) as e:
logger.exception(e)
raise ItoaValidationError('Could not extract "data" from payload. Check input.', logger)
return form_data
@staticmethod
def _extract_csv_payload(payload):
"""
Internal method that extracts csv data from a request payload and creates a file handle from it.
@type: str
@param payload: payload received by handler from REST request
@rtype: json
@return: CSV file handle extracted from the REST args payload, presented in a json object
"""
# Split the payload up to grab just the data portion
# Payload is in this format: <BOUNDARY>\r\n<METADATA>\r\n\r\n<CSV CONTENT><\r\n><BOUNDARY>\r\n
headers_and_data = payload.split('\r\n\r\n')
data_and_end_boundary = headers_and_data[1].rstrip().rpartition('\r\n')
data = itsi_py3.unicode(data_and_end_boundary[0])
string_as_file = StringIO(data)
return {'csvfile': string_as_file}
class FileDownload(object):
def __init__(self, filename, base64_content):
self.filename = filename
self.content = base64_content