Collect Workday audit logs
This document explains how to ingest Workday audit logs to Google Security Operations using AWS S3. The parser first identifies the specific event type from the logs based on pattern analysis of the CSV data. Then, it extracts and structures relevant fields according to the identified type, mapping them to a unified data model (UDM) for consistent security analysis.
Before you begin
Make sure you have the following prerequisites:
- Google SecOps instance
- Privileged access to AWS
- Privileged access to Workday
Configure AWS S3 bucket and IAM for Google SecOps
- Create Amazon S3 bucket following this user guide: Creating a bucket.
- Save bucket Name and Region for future reference (for example,
workday-audit-logs
). - Create a User following this user guide: Creating an IAM user.
- Select the created User.
- Select Security credentials tab.
- Click Create Access Key in section Access Keys.
- Select Third-party service as Use case.
- Click Next.
- Optional: Add description tag.
- Click Create access key.
- Click Download CSV file for save the Access Key and Secret Access Key for future reference.
- Click Done.
- Select Permissions tab.
- Click Add permissions in section Permissions policies.
- Select Add permissions.
- Select Attach policies directly.
- Search for and select the AmazonS3FullAccess policy.
- Click Next.
- Click Add permissions.
Create Workday Integration System User (ISU)
- In Workday, search for Create Integration System User > OK.
- Fill in the User Name (for example,
audit_s3_user
). - Click OK.
- Reset the password by going to Related Actions > Security > Reset Password.
- Select Maintain Password Rules to prevent the password from expiring.
- Search for Create Security Group > Integration System Security Group (Unconstrained).
- Provide a name (for example,
ISU_Audit_S3
) and add the ISU to Integration System Users. - Search for Domain Security Policies for Functional Area > System.
- For Audit Trail, select Actions > Edit Permissions.
- Under Get Only, add the
ISU_Audit_S3
group. - Click OK > Activate Pending Security Policy Changes.
Configure Workday Custom Report
- In Workday, search Create Custom Report.
- Provide the following configuration details:
- Name: Enter a unique name (for example,
Audit_Trail_BP_JSON
). - Type: Select Advanced.
- Data Source: Select Audit Trail – Business Process.
- Click OK.
- Optional: Add filters on Business Process Type or Effective Date.
- Name: Enter a unique name (for example,
- Go to Output tab.
- Select Enable as Web Service, Optimized for Performance and select JSON Format.
- Click OK > Done.
- Open the report and click Share > add
ISU_Audit_S3
with View permission > OK. - Go to Related Actions > Web Service > View URLs.
- Copy the JSON URL (for example,
https://wd-services1.workday.com/ccx/service/customreport2/<tenant>/<user>/Audit_Trail_BP_JSON?format=json
).
Configure the IAM policy and role for S3 uploads
Policy JSON (replace
workday-audit-logs
if you entered a different bucket name):{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowPutWorkdayObjects", "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::workday-audit-logs/*" } ] }
Go to AWS console > IAM > Policies > Create policy > JSON tab.
Copy and paste the policy.
Click Next > Create policy.
Go to IAM > Roles > Create role > AWS service > Lambda.
Attach the newly created policy.
Name the role
WriteWorkdayToS3Role
and click Create role.
Create the Lambda function
Setting | Value |
---|---|
Name | workday_audit_to_s3 |
Runtime | Python 3.13 |
Architecture | x86_64 |
Execution role | WriteWorkdayToS3Role |
After the function is created, open the Code tab, delete the stub and paste the code below (
workday_audit_to_s3.py
).#!/usr/bin/env python3 import os, json, gzip, io, uuid, base64, datetime as dt, urllib.request, urllib.error import boto3 WD_USER = os.environ["WD_USER"] WD_PASS = os.environ["WD_PASS"] WD_URL = os.environ["WD_URL"] S3_BUCKET = os.environ["S3_BUCKET_NAME"] def fetch_report() -> bytes: credentials = f"{WD_USER}:{WD_PASS}".encode() auth_header = b"Basic " + base64.b64encode(credentials) req = urllib.request.Request(WD_URL, headers={"Authorization": auth_header.decode()}) with urllib.request.urlopen(req, timeout=30) as r: return r.read() # raw JSON bytes def upload(payload: bytes, ts: dt.datetime) -> None: key = f"{ts:%Y/%m/%d}/workday-audit-{uuid.uuid4()}.json.gz" buf = io.BytesIO() with gzip.GzipFile(fileobj=buf, mode="w") as gz: gz.write(payload) buf.seek(0) boto3.client("s3").upload_fileobj(buf, S3_BUCKET, key) def lambda_handler(event=None, context=None): now = dt.datetime.utcnow().replace(microsecond=0) data = fetch_report() upload(data, now) print(f"Uploaded Workday audit report ({len(data)} bytes raw)") if __name__ == "__main__": lambda_handler()
Go to Configuration > Environment variables > Edit > Add new environment variable.
Enter the following environment variables, replacing with your value.
Environment variables
Key Example Values WD_USER
audit_s3_user
WD_PASS
Wrokday-Password
WD_URL
https://.../Audit_Trail_BP_JSON?format=json
S3_BUCKET_NAME
workday-audit-logs
After the function is created, stay on its page (or open Lambda > Functions > your‑function).
Select the Configuration tab.
In the General configuration panel click Edit.
Change Timeout to 5 minutes (300 seconds) and click Save.
Schedule the Lambda function (EventBridge Scheduler)
- Go to Configuration > Triggers > Add trigger > EventBridge Scheduler > Create rule.
- Provide the following configuration details:
- Name:
daily-workday-audit export
. - Schedule pattern: Cron expression.
- Expression:
20 2 * * ? *
(runs daily at 02:20 UTC).
- Name:
- Leave the rest as default and click Create.
Configure a feed in Google SecOps to ingest Workday Audit logs
- Go to SIEM Settings > Feeds.
- Click + Add New Feed.
- In the Feed name field, enter a name for the feed (for example,
Workday Audit Logs
). - Select Amazon S3 V2 as the Source type.
- Select Workday Audit as the Log type.
- Click Get A Service Account.
- Click Next.
- Specify values for the following input parameters:
- S3 URI: The bucket URI
s3://workday-audit-logs/
.- Replace
workday-audit-logs
with the actual name of the bucket.
- Replace
- Source deletion options: Select deletion option according to your preference.
- Maximum File Age: Include files modified in the last number of days. Default is 180 Days.
- Access Key ID: User access key with access to the s3 bucket.
- Secret Access Key: User secret key with access to the s3 bucket.
- Asset namespace: The asset namespace.
- Ingestion labels: The label to be applied to the events from this feed.
- S3 URI: The bucket URI
- Click Next.
- Review your new feed configuration in the Finalize screen, and then click Submit.
UDM Mapping Table
Log Field | UDM Mapping | Logic |
---|---|---|
Account |
metadata.event_type | If the "Account" field is not empty, the "metadata.event_type" field is set to "USER_RESOURCE_UPDATE_CONTENT". |
Account |
principal.user.primaryId | The userid is extracted from the "Account" field using a grok pattern and mapped to principal.user.primaryId . |
Account |
principal.user.primaryName | The user display name is extracted from the "Account" field using a grok pattern and mapped to "principal.user.primaryName". |
ActivityCategory |
metadata.event_type | If the "ActivityCategory" field is "READ", the "metadata.event_type" field is set to "RESOURCE_READ". If "WRITE", it's set to "RESOURCE_WRITTEN". |
ActivityCategory |
metadata.product_event_type | Directly mapped from the "ActivityCategory" field. |
AffectedGroups |
target.user.group_identifiers | Directly mapped from the "AffectedGroups" field. |
Area |
target.resource.attribute.labels.area.value | Directly mapped from the "Area" field. |
AuthType |
extensions.auth.auth_details | Directly mapped from the "AuthType" field. |
AuthType |
extensions.auth.type | Mapped from the "AuthType" field to different authentication types defined in the UDM based on specific values. |
CFIPdeConexion |
src.domain.name | If the "CFIPdeConexion" field is not a valid IP address, it's mapped to "src.domain.name". |
CFIPdeConexion |
target.ip | If the "CFIPdeConexion" field is a valid IP address, it's mapped to "target.ip". |
ChangedRelationship |
metadata.description | Directly mapped from the "ChangedRelationship" field. |
ClassOfInstance |
target.resource.attribute.labels.class_instance.value | Directly mapped from the "ClassOfInstance" field. |
column18 |
about.labels.utub.value | Directly mapped from the "column18" field. |
CreatedBy |
principal.user.userid | The userid is extracted from the "CreatedBy" field using a grok pattern and mapped to "principal.user.userid". |
CreatedBy |
principal.user.user_display_name | The user display name is extracted from the "CreatedBy" field using a grok pattern and mapped to "principal.user.user_display_name". |
Domain |
about.domain.name | Directly mapped from the "Domain" field. |
EffectiveDate |
@timestamp | Parsed to "@timestamp" after converting to "yyyy-MM-dd HH:mm:ss.SSSZ" format. |
EntryMoment |
@timestamp | Parsed to "@timestamp" after converting to "ISO8601" format. |
EventType |
security_result.description | Directly mapped from the "EventType" field. |
Form |
target.resource.name | Directly mapped from the "Form" field. |
InstancesAdded |
about.resource.attribute.labels.instances_added.value | Directly mapped from the "InstancesAdded" field. |
InstancesAdded |
target.user.attribute.roles.instances_added.name | Directly mapped from the "InstancesAdded" field. |
InstancesRemoved |
about.resource.attribute.labels.instances_removed.value | Directly mapped from the "InstancesRemoved" field. |
InstancesRemoved |
target.user.attribute.roles.instances_removed.name | Directly mapped from the "InstancesRemoved" field. |
IntegrationEvent |
target.resource.attribute.labels.integration_event.value | Directly mapped from the "IntegrationEvent" field. |
IntegrationStatus |
security_result.action_details | Directly mapped from the "IntegrationStatus" field. |
IntegrationSystem |
target.resource.name | Directly mapped from the "IntegrationSystem" field. |
IP |
src.domain.name | If the "IP" field is not a valid IP address, it's mapped to "src.domain.name". |
IP |
src.ip | If the "IP" field is a valid IP address, it's mapped to "src.ip". |
IsDeviceManaged |
additional.fields.additional1.value.string_value | If the "IsDeviceManaged" field is "N", the value is set to "Successful". Otherwise, it's set to "Failed login occurred". |
IsDeviceManaged |
additional.fields.additional2.value.string_value | If the "IsDeviceManaged" field is "N", the value is set to "Successful". Otherwise, it's set to "Invalid Credentials". |
IsDeviceManaged |
additional.fields.additional3.value.string_value | If the "IsDeviceManaged" field is "N", the value is set to "Successful". Otherwise, it's set to "Account Locked". |
IsDeviceManaged |
security_result.action_details | Directly mapped from the "IsDeviceManaged" field. |
OutputFiles |
about.file.full_path | Directly mapped from the "OutputFiles" field. |
Person |
principal.user.primaryId | If the "Person" field starts with "INT", the userid is extracted using a grok pattern and mapped to "principal.user.primaryId". |
Person |
principal.user.primaryName | If the "Person" field starts with "INT", the user display name is extracted using a grok pattern and mapped to "principal.user.primaryName". |
Person |
principal.user.user_display_name | If the "Person" field doesn't start with "INT", it's directly mapped to "principal.user.user_display_name". |
Person |
metadata.event_type | If the "Person" field is not empty, the "metadata.event_type" field is set to "USER_RESOURCE_UPDATE_CONTENT". |
ProcessedTransaction |
target.resource.attribute.creation_time | Parsed to "target.resource.attribute.creation_time" after converting to "dd/MM/yyyy HH:mm:ss,SSS (ZZZ)", "dd/MM/yyyy, HH:mm:ss,SSS (ZZZ)", or "MM/dd/yyyy, HH:mm:ss.SSS A ZZZ" format. |
ProgramBy |
principal.user.userid | Directly mapped from the "ProgramBy" field. |
RecurrenceEndDate |
principal.resource.attribute.last_update_time | Parsed to "principal.resource.attribute.last_update_time" after converting to "yyyy-MM-dd" format. |
RecurrenceStartDate |
principal.resource.attribute.creation_time | Parsed to "principal.resource.attribute.creation_time" after converting to "yyyy-MM-dd" format. |
RequestName |
metadata.description | Directly mapped from the "RequestName" field. |
ResponseMessage |
security_result.summary | Directly mapped from the "ResponseMessage" field. |
RestrictedToEnvironment |
security_result.about.hostname | Directly mapped from the "RestrictedToEnvironment" field. |
RevokedSecurity |
security_result.outcomes.outcomes.value | Directly mapped from the "RevokedSecurity" field. |
RunFrequency |
principal.resource.attribute.labels.run_frequency.value | Directly mapped from the "RunFrequency" field. |
ScheduledProcess |
principal.resource.name | Directly mapped from the "ScheduledProcess" field. |
SecuredTaskExecuted |
target.resource.name | Directly mapped from the "SecuredTaskExecuted" field. |
SecureTaskExecuted |
metadata.event_type | If the "SecureTaskExecuted" field contains "Create", the "metadata.event_type" field is set to "USER_RESOURCE_CREATION". |
SecureTaskExecuted |
target.resource.name | Directly mapped from the "SecureTaskExecuted" field. |
SentTime |
@timestamp | Parsed to "@timestamp" after converting to "ISO8601" format. |
SessionId |
network.session_id | Directly mapped from the "SessionId" field. |
ShareBy |
target.user.userid | Directly mapped from the "ShareBy" field. |
SignOffTime |
additional.fields.additional4.value.string_value | The "AuthFailMessage" field value is placed within the "additional.fields" array with the key "Enterprise Interface Builder". |
SignOffTime |
metadata.description | Directly mapped from the "AuthFailMessage" field. |
SignOffTime |
metadata.event_type | If the "SignOffTime" field is empty, the "metadata.event_type" field is set to "USER_LOGIN". Otherwise, it's set to "USER_LOGOUT". |
SignOffTime |
principal.user.attribute.last_update_time | Parsed to "principal.user.attribute.last_update_time" after converting to "ISO8601" format. |
SignOnIp |
src.domain.name | If the "SignOnIp" field is not a valid IP address, it's mapped to "src.domain.name". |
SignOnIp |
src.ip | If the "SignOnIp" field is a valid IP address, it's mapped to "src.ip". |
Status |
metadata.product_event_type | Directly mapped from the "Status" field. |
SystemAccount |
principal.user.email_addresses | The email address is extracted from the "SystemAccount" field using a grok pattern and mapped to "principal.user.email_addresses". |
SystemAccount |
principal.user.primaryId | The userid is extracted from the "SystemAccount" field using a grok pattern and mapped to "principal.user.primaryId". |
SystemAccount |
principal.user.primaryName | The user display name is extracted from the "SystemAccount" field using a grok pattern and mapped to "principal.user.primaryName". |
SystemAccount |
src.user.userid | The secondary userid is extracted from the "SystemAccount" field using a grok pattern and mapped to "src.user.userid". |
SystemAccount |
src.user.user_display_name | The secondary user display name is extracted from the "SystemAccount" field using a grok pattern and mapped to "src.user.user_display_name". |
SystemAccount |
target.user.userid | The target userid is extracted from the "SystemAccount" field using a grok pattern and mapped to "target.user.userid". |
Target |
target.user.user_display_name | Directly mapped from the "Target" field. |
Template |
about.resource.name | Directly mapped from the "Template" field. |
Tenant |
target.asset.hostname | Directly mapped from the "Tenant" field. |
TlsVersion |
network.tls.version | Directly mapped from the "TlsVersion" field. |
Transaction |
security_result.action_details | Directly mapped from the "Transaction" field. |
TransactionType |
security_result.summary | Directly mapped from the "TransactionType" field. |
TypeForm |
target.resource.resource_subtype | Directly mapped from the "TypeForm" field. |
UserAgent |
network.http.parsed_user_agent | Parsed from the "UserAgent" field using the "useragent" filter. |
UserAgent |
network.http.user_agent | Directly mapped from the "UserAgent" field. |
WorkdayAccount |
target.user.user_display_name | The user display name is extracted from the "WorkdayAccount" field using a grok pattern and mapped to "target.user.user_display_name". |
WorkdayAccount |
target.user.userid | The userid is extracted from the "WorkdayAccount" field using a grok pattern and mapped to "target.user.userid". |
additional.fields.additional1.key | Set to "FailedSignOn". | |
additional.fields.additional2.key | Set to "InvalidCredentials". | |
additional.fields.additional3.key | Set to "AccountLocked". | |
additional.fields.additional4.key | Set to "Enterprise Interface Builder". | |
metadata.event_type | Set to "GENERIC_EVENT" initially and then updated based on the logic involving other fields. | |
metadata.event_type | Set to "USER_CHANGE_PERMISSIONS" for specific event types. | |
metadata.event_type | Set to "RESOURCE_WRITTEN" for specific event types. | |
metadata.log_type | Hardcoded to "WORKDAY_AUDIT". | |
metadata.product_name | Hardcoded to "Enterprise Interface Builder". | |
metadata.vendor_name | Hardcoded to "Workday". | |
principal.asset.category | Set to "Phone" if the "DeviceType" field is "Phone". | |
principal.resource.resource_type | Hardcoded to "TASK" if the "ScheduledProcess" field is not empty. | |
security_result.action | Set to "ALLOW" or "FAIL" based on the values of "FailedSignOn", "IsDeviceManaged", "InvalidCredentials", and "AccountLocked" fields. | |
security_result.summary | Set to "Successful" or specific error messages based on the values of "FailedSignOn", "IsDeviceManaged", "InvalidCredentials", and "AccountLocked" fields. | |
target.resource.resource_type | Hardcoded to "TASK" for specific event types. | |
target.resource.resource_type | Hardcoded to "DATASET" if the "TypeForm" field is not empty. | |
message |
principal.user.email_addresses | Extracts the email address from the "message" field using a grok pattern and merges it into "principal.user.email_addresses" if a specific pattern is matched. |
message |
src.user.userid | Clears the field if the "event.idm.read_only_udm.principal.user.userid" field matches the extracted "user_target" from the "message" field. |
message |
src.user.user_display_name | Clears the field if the "event.idm.read_only_udm.principal.user.userid" field matches the extracted "user_target" from the "message" field. |
message |
target.user.userid | Extracts the userid from the "message" field using a grok pattern and maps it to "target.user.userid" if a specific pattern is matched. |
Need more help? Get answers from Community members and Google SecOps professionals.