Workday Sign On Event Collector
Use the Workday Sign On Event Collector integration to get sign on logs from Workday.
Analytics & SIEM · Workday
Details
| ID | Workday Sign On Event Collector |
|---|---|
| Provider | Workday |
| Category | Analytics & SIEM |
| From Version | 8.2.0 |
| Docker Image | demisto/python3:3.12.13.10116658 |
| Supported Modules | Agentix XSIAM Cloud Posture Security |
README
Use the Workday Sign On Event Collector integration to get sign on logs from Workday.
This integration was integrated and tested with version v37.0 of Workday Sign On Event Collector.
Configure Workday Sign On Event Collector in Cortex
| Parameter | Description | Required |
|---|---|---|
| Server URL (e.g., https://services1.myworkday.com) | API Endpoint of Workday server. Can be obtained from View API Clients report in Workday application. | True |
| Tenant Name | The name of the Workday Tenant. Can be obtained from View API Clients report in Workday application. | True |
| API Version | Some tenants may be using different API versions. Check in your Workday tenant to determine the version to use. Default is v40.0. | False |
| Username | True | |
| Password | True | |
| Trust any certificate (not secure) | False | |
| Use system proxy settings | False | |
| Max events per fetch | The maximum number of sign on events to retrieve. Large amount of events may cause performance issues. | False |
| Events Fetch Interval | False |
Commands
You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.
workday-get-sign-on-events
Returns sign on events extracted from Workday. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to events duplication and exceeding the API request limitation.
Base Command
workday-get-sign-on-events
Input
| Argument Name | Description | Required |
|---|---|---|
| should_push_events | Set this argument to True in order to create events, otherwise the command will only display them. Possible values are: True, False. Default is False. | Required |
| limit | The maximum number of events to return. Default is 1000. | Optional |
| from_date | The date and time of the earliest event. The default timezone is UTC/GMT. The time format is “{yyyy}-{mm}-{dd}T{hh}:{mm}:{ss}Z”. Example: “2021-05-18T13:45:14Z” indicates May 18, 2021, 1:45PM UTC. | Optional |
| to_date | The time format is “{yyyy}-{mm}-{dd}T{hh}:{mm}:{ss}Z”. Example: “2021-05-18T13:45:14Z” indicates May 18, 2021, 1:45PM UTC. | Optional |
| relative_from_date | The query from date, for example, “5 minutes”. Be advised, it is strongly suggested to keep this parameter limited in time. | Optional |
Context Output
There is no context output for this command.
Configuration parameters
base_url— Server URL (e.g., https://services1.myworkday.com) (required)tenant_name— Tenant Name (required)api_version— API Versioncredentials— Username (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsmax_fetch— Max events per fetcheventFetchInterval— Events Fetch Interval
Commands (1)
-
workday-get-sign-on-eventsReturns sign on events extracted from Workday. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to events duplication and exceeding the API request limitation.
from xml.sax.saxutils import escape import demistomock as demisto import urllib3 from CommonServerPython import * # noqa # pylint: disable=unused-wildcard-import # Disable insecure warnings urllib3.disable_warnings() VENDOR = "workday" PRODUCT = "signon" REQUEST_DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ" # Old format for making requests EVENT_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%f%z" # New format for processing events TIMEDELTA = 1 VERSION_PATTERN = re.compile(r"^v\d+\.\d+$") # To preserve the XML template, we are ignoring the long line ruff validation # ruff: noqa: E501 def get_api_version(params, default="v40.0"): """ Retrieve and validate the API version from the parameters dictionary. Parameters: params (dict): Dictionary containing parameters, potentially including 'api_version'. default (str): The default API version to use if 'api_version' is not provided or invalid. Returns: str: The validated API version. """ api_version = params.get("api_version", default) if VERSION_PATTERN.match(api_version): return api_version return default def get_from_time(seconds_ago: int) -> str: current_time = datetime.now(tz=timezone.utc) from_time = current_time - timedelta(seconds=seconds_ago) return from_time.strftime(REQUEST_DATE_FORMAT) def fletcher16(data: bytes) -> int: """ Compute the Fletcher-16 checksum for the given data. The Fletcher-16 checksum is a simple and fast checksum algorithm that provides a checksum value based on the input data. It's not as collision-resistant as cryptographic hashes but is faster and can be suitable for non-security-critical applications. Parameters: - data (bytes): The input data for which the checksum is to be computed. Returns: - int: The computed Fletcher-16 checksum value. """ sum1, sum2 = 0, 0 for byte in data: sum1 = (sum1 + byte) % 256 sum2 = (sum2 + sum1) % 256 return (sum2 << 8) | sum1 def generate_pseudo_id(event: dict) -> str: """ Compute a checksum for the given event using the Fletcher-16 algorithm. This function takes the entire event, serializes it to a JSON string, converts that string to bytes, and then computes a Fletcher-16 checksum for the byte data. Parameters: - event (dict): The entire event dictionary. Returns: - str: The unique ID, which is the computed Fletcher-16 checksum value concatenated with the event's Signon_DateTime. """ # Serialize the entire event to a JSON string and encode that to bytes event_str = json.dumps(event, sort_keys=True) data = event_str.encode() # Calculate the checksum checksum = fletcher16(data) # Create a unique ID by concatenating the checksum with the Signon_DateTime try: unique_id = f"{checksum}_{event['Signon_DateTime']}" except KeyError as e: raise DemistoException( f"While calculating the pseudo ID for an event, an event without a Signon_DateTime was found.\nError: {e}" ) return unique_id """ CLIENT CLASS """ class Client(BaseClient): """ Client will implement the service API and should not contain any Demisto logic. Should only do requests and return data. """ def __init__(self, params: dict): base_url = params.get("base_url", "invalid") tenant_name = params.get("tenant_name") api_version = get_api_version(params) username = params.get("credentials", {}).get("identifier") password = params.get("credentials", {}).get("password") verify_certificate = not params.get("insecure", False) proxy = params.get("proxy", False) if not base_url.startswith("https://"): raise ValueError("Invalid base URL. Should begin with https://") base_api_url = f"{base_url}/ccx/service/{tenant_name}/Identity_Management/{api_version}" headers = {"content-type": "text/xml;charset=UTF-8"} super().__init__(base_url=base_api_url, verify=verify_certificate, proxy=proxy, headers=headers) self.tenant_name = tenant_name self.username = escape(username) self.password = escape(password) self.api_version = api_version def generate_workday_account_signons_body( self, page: int, count: int, to_time: Optional[str] = None, from_time: Optional[str] = None, ) -> str: """ Generates XML body for Workday Account Signons Request. :type page: ``int`` :param page: Page number. :type count: ``int`` :param count: Number of results per page. :type to_time: ``Optional[str]`` :param to_time: End time for fetching events. :type from_time: ``Optional[str]`` :param from_time: Start time for fetching events. :return: XML body as string. :rtype: ``str`` """ return f""" <soapenv:Envelope xmlns:bsvc="urn:com.workday/bsvc" xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Header> <wsse:Security soapenv:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> <wsse:UsernameToken> <wsse:Username>{self.username}</wsse:Username> <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{self.password}</wsse:Password> </wsse:UsernameToken> </wsse:Security> </soapenv:Header> <soapenv:Body> <bsvc:Get_Workday_Account_Signons_Request xmlns:bsvc="urn:com.workday/bsvc" bsvc:version="{self.api_version}"> <bsvc:Request_Criteria> <!-- Optional: --> <bsvc:From_DateTime>{from_time}</bsvc:From_DateTime> <!-- Optional: --> <bsvc:To_DateTime>{to_time}</bsvc:To_DateTime> </bsvc:Request_Criteria> <!-- Optional: --> <bsvc:Response_Filter> <bsvc:Page>{page}</bsvc:Page> <!-- Optional: --> <bsvc:Count>{count}</bsvc:Count> <bsvc:As_Of_Entry_DateTime>{from_time}</bsvc:As_Of_Entry_DateTime> </bsvc:Response_Filter> </bsvc:Get_Workday_Account_Signons_Request> </soapenv:Body> </soapenv:Envelope> """ # noqa:E501 def generate_test_payload(self, from_time: str, to_time: str) -> str: return f""" <soapenv:Envelope xmlns:bsvc="urn:com.workday/bsvc" xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"> <soapenv:Header> <wsse:Security soapenv:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"> <wsse:UsernameToken wsu:Id="UsernameToken-BF23D830F28697AA1614674076904673"> <wsse:Username>{self.username}</wsse:Username> <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">{self.password}</wsse:Password> </wsse:UsernameToken> </wsse:Security> </soapenv:Header> <soapenv:Body> <bsvc:Get_Workday_Account_Signons_Request xmlns:bsvc="urn:com.workday/bsvc" bsvc:version="{self.api_version}"> <bsvc:Request_Criteria> <!-- Optional: --> <bsvc:From_DateTime>{from_time}</bsvc:From_DateTime> <!-- Optional: --> <bsvc:To_DateTime>{to_time}</bsvc:To_DateTime> </bsvc:Request_Criteria> <!-- Optional: --> <bsvc:Response_Filter> <bsvc:Page>1</bsvc:Page> <!-- Optional: --> <bsvc:Count>1</bsvc:Count> </bsvc:Response_Filter> </bsvc:Get_Workday_Account_Signons_Request> </soapenv:Body> </soapenv:Envelope> """ # noqa:E501 def retrieve_events( self, page: int, count: int, to_time: Optional[str] = None, from_time: Optional[str] = None, ) -> tuple: """ Retrieves events from Workday. :type page: ``int`` :param page: Page number. :type count: ``int`` :param count: Number of results per page. :type to_time: ``Optional[str]`` :param to_time: End time for fetching events. :type from_time: ``Optional[str]`` :param from_time: Start time for fetching events. :return: Tuple containing raw JSON response and account sign-on data. :rtype: ``Tuple`` """ # Make the HTTP request. raw_response = self._http_request( method="POST", url_suffix="", data=self.generate_workday_account_signons_body(page, count, to_time, from_time), resp_type="text", timeout=120, ) raw_json_response, account_signon_data = convert_to_json(raw_response) total_pages = int( demisto.get( obj=raw_json_response, field="Envelope.Body.Get_Workday_Account_Signons_Response.Response_Results", defaultParam={}, ).get("Total_Pages", "1") ) return account_signon_data, total_pages def test_connectivity(self) -> str: """ Tests API connectivity and authentication. :return: 'ok' if test passed, else exception. :rtype: ``str`` """ seconds_ago = 5 from_time = get_from_time(seconds_ago) to_time = datetime.now(tz=timezone.utc).strftime(REQUEST_DATE_FORMAT) payload = self.generate_test_payload(from_time=from_time, to_time=to_time) self._http_request(method="POST", url_suffix="", data=payload, resp_type="text", timeout=120) return "ok" """ HELPER FUNCTIONS """ def convert_to_json(response: str | dict) -> tuple[Dict[str, Any], Dict[str, Any]]: """ Convert an XML response to a JSON object and extract the 'Workday_Account_Signons' data. :param response: XML response to be converted :return: Tuple containing the full converted response and the extracted 'Workday_Account_Signons' data. :raises ValueError: If the expected data cannot be found in the response. """ if type(response) is dict: raw_json_response = response else: try: raw_json_response = json.loads(xml2json(response)) except Exception as e: raise ValueError(f"Error parsing XML to JSON: {e}") # Get the 'Get_Workday_Account_Signons_Response' dictionary safely response_data = demisto.get(raw_json_response, "Envelope.Body.Get_Workday_Account_Signons_Response") if not response_data: response_data = raw_json_response.get("Get_Workday_Account_Signons_Response", {}) account_signon_data = response_data.get("Response_Data", {}) # Ensure 'Workday_Account_Signon' is a list workday_account_signons = account_signon_data.get("Workday_Account_Signon") if isinstance(workday_account_signons, dict): account_signon_data["Workday_Account_Signon"] = [workday_account_signons] return raw_json_response, account_signon_data def process_and_filter_events(events: list, from_time: str, previous_run_pseudo_ids: set) -> tuple: non_duplicates = [] duplicates = [] pseudo_ids_for_next_iteration = set() try: from_datetime = datetime.strptime(from_time, EVENT_DATE_FORMAT).replace(tzinfo=timezone.utc) except ValueError: # On first run, the from_time is in UTC since that is what's sent in the request, this covers this scenario from_datetime = datetime.strptime(from_time, REQUEST_DATE_FORMAT).replace(tzinfo=timezone.utc) most_recent_event_time = datetime.min.replace(tzinfo=timezone.utc) for event in events: event_datetime = datetime.strptime(event["Signon_DateTime"], EVENT_DATE_FORMAT).replace(tzinfo=timezone.utc) # Add '_time' key to each event event["_time"] = event.get("Signon_DateTime") # Update the most recent event time if event_datetime > most_recent_event_time: most_recent_event_time = event_datetime # Check for duplicates within ±1 second of from_time if abs((event_datetime - from_datetime).total_seconds()) <= 1: event_pseudo_id = generate_pseudo_id(event) if event_pseudo_id not in previous_run_pseudo_ids: non_duplicates.append(event) else: duplicates.append(event_pseudo_id) else: non_duplicates.append(event) # Generate pseudo IDs for events within the last second of the most recent event last_second_start_time = most_recent_event_time - timedelta(seconds=TIMEDELTA) if duplicates: demisto.debug(f"[Dedup] Found {len(duplicates)} duplicate events: {duplicates}") for event in non_duplicates: event_datetime = datetime.strptime(event["_time"], EVENT_DATE_FORMAT).replace(tzinfo=timezone.utc) if event_datetime >= last_second_start_time: event_pseudo_id = generate_pseudo_id(event) pseudo_ids_for_next_iteration.add(event_pseudo_id) return non_duplicates, pseudo_ids_for_next_iteration def fetch_sign_on_logs(client: Client, limit_to_fetch: int, from_date: str, to_date: str): """ Fetches Sign On logs from workday. Args: client: Client object. limit_to_fetch: limit of logs to fetch from Workday. from_date: Events from time. to_date: Events to time. Returns: Sign On Events fetched from Workday. """ sign_on_logs: list = [] page = 1 # We assume that we will need to make one call at least res, total_pages = client.retrieve_events(from_time=from_date, to_time=to_date, page=1, count=999) sign_on_events_from_api = res.get("Workday_Account_Signon", []) sign_on_logs.extend(sign_on_events_from_api) total_fetched = len(sign_on_events_from_api) demisto.debug( f"[Pagination] Request indicates a total of {total_pages} pages to paginate. " f"Fetched {total_fetched} events from page 1." ) pages_remaining = total_pages - 1 while (page <= total_pages and pages_remaining != 0) and res: page += 1 remaining_to_fetch = limit_to_fetch - total_fetched if remaining_to_fetch <= 0: demisto.debug(f"[Pagination] Reached fetch limit of {limit_to_fetch}. Stopping pagination.") break res, _ = client.retrieve_events(from_time=from_date, to_time=to_date, page=page, count=remaining_to_fetch) sign_on_events_from_api = res.get("Workday_Account_Signon", []) fetched_count = len(sign_on_events_from_api) total_fetched += fetched_count demisto.debug(f"[Pagination] Fetched {fetched_count} sign on logs from page {page}.") sign_on_logs.extend(sign_on_events_from_api) pages_remaining -= 1 demisto.debug(f"[Pagination] {pages_remaining} pages left to fetch. Total fetched so far: {total_fetched}.") return sign_on_logs """ COMMAND FUNCTIONS """ def get_sign_on_events_command(client: Client, from_date: str, to_date: str, limit: int) -> tuple[list, CommandResults]: """ Args: limit: The maximum number of logs to return. to_date: date to fetch events from. from_date: date to fetch events to. client: Client object. Returns: Sign on logs from Workday. """ sign_on_events = fetch_sign_on_logs(client=client, limit_to_fetch=limit, from_date=from_date, to_date=to_date) [_event.update({"_time": _event.get("Signon_DateTime")}) for _event in sign_on_events] demisto.info(f"[Get Events] Got a total of {len(sign_on_events)} events between the time {from_date} to {to_date}") readable_output = tableToMarkdown( "Sign On Events List:", sign_on_events, removeNull=True, headerTransform=lambda x: string_to_table_header(camel_case_to_underscore(x)), ) return sign_on_events, CommandResults(readable_output=readable_output) def fetch_sign_on_events_command(client: Client, max_fetch: int, last_run: dict): """ Fetches sign on logs from Workday. Args: client: Client object. max_fetch: max logs to fetch set by customer. last_run: last run object. Returns: Sign on logs from Workday. """ current_time = datetime.utcnow() if "last_fetch_time" not in last_run: first_fetch_time = current_time - timedelta(minutes=1) first_fetch_str = first_fetch_time.strftime(REQUEST_DATE_FORMAT) from_date = last_run.get("last_fetch_time", first_fetch_str) else: from_date = last_run.get("last_fetch_time") # Checksums in this context is used as an ID since none is provided directly from Workday. # This is to prevent duplicates. previous_run_pseudo_ids = set(last_run.get("previous_run_pseudo_ids") or []) to_date = datetime.now(tz=timezone.utc).strftime(REQUEST_DATE_FORMAT) demisto.debug(f"[Fetch] Getting Sign On Events {from_date=}, {to_date=}.") sign_on_events = fetch_sign_on_logs(client=client, limit_to_fetch=max_fetch, from_date=from_date, to_date=to_date) if sign_on_events: demisto.debug(f"[Fetch] Got {len(sign_on_events)} sign_on_events. Begin processing.") non_duplicates, pseudo_ids_for_next_iteration = process_and_filter_events( events=sign_on_events, previous_run_pseudo_ids=previous_run_pseudo_ids, from_time=from_date ) demisto.debug(f"[Fetch] Done processing {len(non_duplicates)} sign_on_events.") last_event = non_duplicates[-1] last_run = { "last_fetch_time": last_event.get("Signon_DateTime"), "previous_run_pseudo_ids": list(pseudo_ids_for_next_iteration), } demisto.debug(f"[Fetch] Saving last run as {last_run}") else: # Handle the case where no events were retrieved last_run["last_fetch_time"] = current_time.strftime(REQUEST_DATE_FORMAT) non_duplicates = [] return non_duplicates, last_run def module_of_testing(client: Client) -> str: # pragma: no cover """Tests API connectivity and authentication Returning 'ok' indicates that the integration works like it is supposed to. Connection to the service is successful. Raises exceptions if something goes wrong. :type client: ``Client`` :param Client: client to use :return: 'ok' if test passed, anything else will fail the test. :rtype: ``str`` """ return client.test_connectivity() """ MAIN FUNCTION """ def main() -> None: # pragma: no cover """main function, parses params and runs command functions""" command = demisto.command() args = demisto.args() params = demisto.params() max_fetch = arg_to_number(params.get("max_fetch")) or 10000 demisto.debug(f"[Main] Command being called is {command}") try: client = Client(params=params) if command == "test-module": return_results(module_of_testing(client)) elif command == "workday-get-sign-on-events": if args.get("relative_from_date", None): from_time = arg_to_datetime( # type:ignore arg=args.get("relative_from_date"), arg_name="Relative datetime", required=False ).strftime(REQUEST_DATE_FORMAT) to_time = datetime.utcnow().strftime(REQUEST_DATE_FORMAT) else: from_time = args.get("from_date") to_time = args.get("to_date") sign_on_events, results = get_sign_on_events_command( client=client, from_date=from_time, to_date=to_time, limit=arg_to_number(args.get("limit", "100"), required=True), # type: ignore ) return_results(results) if argToBoolean(args.get("should_push_events", "true")): send_events_to_xsiam(sign_on_events, vendor=VENDOR, product=PRODUCT) elif command == "fetch-events": last_run = demisto.getLastRun() demisto.debug(f"[Main] Starting new fetch with last_run as {last_run}") sign_on_events, new_last_run = fetch_sign_on_events_command(client=client, max_fetch=max_fetch, last_run=last_run) demisto.debug(f"[Main] Done fetching events, sending to XSIAM {len(sign_on_events)} events.") send_events_to_xsiam(sign_on_events, vendor=VENDOR, product=PRODUCT) if new_last_run: # saves next_run for the time fetch-events is invoked demisto.info(f"[Main] Setting new last_run to {new_last_run}") demisto.setLastRun(new_last_run) else: raise NotImplementedError(f"command {command} is not implemented.") # Log exceptions and return errors except Exception as e: return_error(f"Failed to execute {demisto.command()} command.\nError:\n{str(e)}\nTraceback:\n{traceback.format_exc()}") """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): main()