Collect MuleSoft Anypoint platform logs
This document explains how to ingest audit-trail events from MuleSoft Anypoint platform logs to Google Security Operations using Cloud Storage.
Before you begin
Make sure you have the following prerequisites:
- A Google SecOps instance
- A Google Cloud project with Cloud Storage API enabled
- Permissions to create and manage Cloud Storage buckets
- Permissions to manage Identity and Access Management (IAM) policies on Cloud Storage buckets
- Permissions to create Cloud Run functions, Pub/Sub topics, and Cloud Scheduler jobs
- Permissions to create service accounts
- Privileged access to the MuleSoft Anypoint Platform
Get the MuleSoft Organization ID
- Sign in to the Anypoint Platform.
- Go to Access Management > Organizations.
- In the Business Groups table, click your organization's name.
- Copy the Organization ID (for example,
0a12b3c4-d5e6-789f-1021-1a2b34cd5e6f).
Alternatively, go to MuleSoft Business Groups and copy the ID from the URL.
Create the MuleSoft Connected App
- Sign in to the Anypoint Platform.
- Go to Access Management > Connected Apps > Create App.
- Provide the following configuration details:
- App name: Enter a unique name (for example,
Google SecOps export). - Select App acts on its own behalf (client credentials).
- App name: Enter a unique name (for example,
- Click Add scopes > Audit Log Viewer > Next.
- Select every Business Group whose logs you need.
- Click Next > Add scopes.
Click Save and copy the Client ID and Client Secret.
Create a Cloud Storage bucket
- Go to the Google Cloud console.
- Select your project or create a new one.
- In the navigation menu, go to Cloud Storage > Buckets.
- Click Create bucket.
Provide the following configuration details:
Setting Value Name your bucket Enter a globally unique name (for example, mulesoft-audit-logs)Location type Choose based on your needs (Region, Dual-region, Multi-region) Location Select the location (for example, us-central1)Storage class Standard (recommended for frequently accessed logs) Access control Uniform (recommended) Protection tools Optional: Enable object versioning or retention policy Click Create.
Create a service account for the Cloud Run function
The Cloud Run function requires a service account with permissions to write to the Cloud Storage bucket.
Create the service account
- In the GCP Console, go to IAM & Admin > Service Accounts.
- Click Create Service Account.
- Provide the following configuration details:
- Service account name: Enter
mulesoft-logs-collector-sa. - Service account description: Enter
Service account for Cloud Run function to collect MuleSoft Anypoint logs.
- Service account name: Enter
- Click Create and Continue.
- In the Grant this service account access to project section:
- Click Select a role.
- Search for and select Storage Object Admin.
- Click + Add another role.
- Search for and select Cloud Run Invoker.
- Click + Add another role.
- Search for and select Cloud Functions Invoker.
- Click Continue.
- Click Done.
These roles are required for:
- Storage Object Admin: Write logs to the Cloud Storage bucket and manage state files
- Cloud Run Invoker: Allow Pub/Sub to invoke the function
- Cloud Functions Invoker: Allow function invocation
Grant IAM permissions on the Cloud Storage bucket
Grant the service account write permissions on the Cloud Storage bucket as follows:
- Go to Cloud Storage > Buckets.
- Click your bucket name.
- Go to the Permissions tab.
- Click Grant access.
- Provide the following configuration details:
- Add principals: Enter the service account email (for example,
mulesoft-logs-collector-sa@PROJECT_ID.iam.gserviceaccount.com). - Assign roles: Select Storage Object Admin.
- Add principals: Enter the service account email (for example,
- Click Save.
Create a Pub/Sub topic
Create a Pub/Sub topic that Cloud Scheduler will publish to and the Cloud Run function will subscribe to as follows:
- In the GCP Console, go to Pub/Sub > Topics.
- Click Create topic.
- Provide the following configuration details:
- Topic ID: Enter
mulesoft-audit-trigger. - Leave other settings as default.
- Topic ID: Enter
- Click Create.
Create a Cloud Run function to collect logs
The Cloud Run function will be triggered by Pub/Sub messages from Cloud Scheduler to fetch logs from the MuleSoft Anypoint API and write them to Cloud Storage.
- In the GCP Console, go to Cloud Run.
- Click Write a function.
In the Configure section, provide the following configuration details:
Setting Value Service name mulesoft-audit-collectorRegion Select the region matching your Cloud Storage bucket (for example, us-central1)Runtime Select Python 3.12 or later In the Trigger section, click Add trigger and select Pub/Sub trigger.
In the Eventarc trigger pane, provide the following configuration details:
- Trigger name: Keep the generated name or enter a name for the trigger.
- Trigger type: Select Google Sources.
- Event provider: Select Pub/Sub.
- Event type: Select google.cloud.pubsub.topic.v1.messagePublished.
- Select a Cloud Pub/Sub topic: Choose the topic
mulesoft-audit-trigger. - Region: Select the same region as the function.
- Service account: Select the service account
mulesoft-logs-collector-sa.
Click Save trigger.
In the Authentication section:
- Select Require authentication.
- CheckIdentity and Access Management (IAM).
Navigate to and expand Containers, Networking, Security.
Go to the Security tab:
- Service account: Select the
mulesoft-logs-collector-saservice account.
- Service account: Select the
Go to the Containers tab:
- Click Variables & Secrets.
- Click + Add variable for each environment variable:
Variable Name Example Value Description GCS_BUCKETmulesoft-audit-logsCloud Storage bucket name GCS_PREFIXmulesoft-auditPrefix for log files STATE_KEYmulesoft-audit-state.jsonState path, outside the log prefix MULE_ORG_IDyour_org_idMuleSoft Organization ID CLIENT_IDyour_client_idConnected App client ID CLIENT_SECRETyour_client_secretConnected App client secret MAX_RECORDS10000Max records per run PAGE_SIZE200Entries per page (API maximum is 200) LOOKBACK_HOURS24Initial lookback period on first run Navigate to Variables & Secrets tab to Requests:
- Request timeout: Enter
600seconds (10 minutes).
- Request timeout: Enter
Go to the Settings tab in Containers:
- In the Resources section:
- Memory: Select 512 MiB or higher.
- CPU: Select 1.
- Click Done.
- In the Resources section:
Navigate to Execution environment:
- Select Default (recommended).
In the Revision scaling section:
- Minimum number of instances: Enter
0. - Maximum number of instances: Enter
100(or adjust based on expected load).
- Minimum number of instances: Enter
Click Create.
Wait for the service to be created (1-2 minutes).
After the service is created, the inline code editor opens automatically.
Add the function code
- Enter main in the Function entry point
In the inline code editor, create two files:
First file - main.py:
import functions_framework from google.cloud import storage from google.cloud.exceptions import NotFound import hashlib import json import os import urllib3 from urllib.parse import urlencode from datetime import datetime, timezone, timedelta import time # Initialize HTTP client with timeouts http = urllib3.PoolManager( timeout=urllib3.Timeout(connect=5.0, read=30.0), retries=False, ) # Initialize Storage client storage_client = storage.Client() # MuleSoft API endpoints TOKEN_URL = "https://anypoint.mulesoft.com/accounts/api/v2/oauth2/token" AUDIT_URL = "https://anypoint.mulesoft.com/audit/v2/organizations/{org_id}/query?cursorPagination=true" # Environment variables GCS_BUCKET = os.environ.get('GCS_BUCKET') GCS_PREFIX = os.environ.get('GCS_PREFIX', 'mulesoft-audit') # STATE_KEY must stay OUTSIDE GCS_PREFIX. The feed ingests every object under # its bucket URI and, with a deletion option selected, deletes what it # transferred. A state file inside the prefix would be ingested as log data and # then deleted, resetting collection and re-ingesting duplicates. STATE_KEY = os.environ.get('STATE_KEY', 'mulesoft-audit-state.json') MULE_ORG_ID = os.environ.get('MULE_ORG_ID') CLIENT_ID = os.environ.get('CLIENT_ID') CLIENT_SECRET = os.environ.get('CLIENT_SECRET') MAX_RECORDS = int(os.environ.get('MAX_RECORDS', '10000')) # The Audit Log Query API returns at most 200 entries per JSON query. PAGE_SIZE = min(int(os.environ.get('PAGE_SIZE', '200')), 200) LOOKBACK_HOURS = int(os.environ.get('LOOKBACK_HOURS', '24')) # The query re-reads this many minutes before the watermark so that events the # platform records late are still collected. Re-read events are removed by hash. OVERLAP_MINUTES = int(os.environ.get('OVERLAP_MINUTES', '2')) class FetchError(Exception): """Raised when the MuleSoft API call fails. The watermark must never advance on a failed fetch, otherwise the events in the failed window are skipped permanently. """ def to_unix_millis(dt: datetime) -> int: """Convert datetime to Unix epoch milliseconds.""" if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return int(dt.astimezone(timezone.utc).timestamp() * 1000) def parse_datetime(value: str) -> datetime: """Parse an ISO 8601 datetime string into an aware datetime.""" if value.endswith("Z"): value = value[:-1] + "+00:00" dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def event_keys(event: dict) -> set: """Return the identity this event can be recognised by. Audit log entries carry no unique event ID, so a record counts as already collected when its full JSON content was seen before. The overlap window returns the same JSON again, which this catches without any knowledge of the payload schema. """ return {'sha256:' + hashlib.sha256( json.dumps(event, sort_keys=True, ensure_ascii=False).encode('utf-8') ).hexdigest()} def event_time(event: dict) -> str: """Return the event timestamp as an ISO 8601 string, or an empty string. Audit log entries carry the event time in `timestamp` as Unix milliseconds. """ raw = event.get('timestamp') if raw is None or raw == '': return '' try: return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc).isoformat() except (TypeError, ValueError): return '' @functions_framework.cloud_event def main(cloud_event): """Fetch MuleSoft Anypoint audit logs and write them to Cloud Storage. Args: cloud_event: CloudEvent object containing the Pub/Sub message. """ if not all([GCS_BUCKET, MULE_ORG_ID, CLIENT_ID, CLIENT_SECRET]): # Raise rather than return: a bare return acks the Pub/Sub message and # reports the run as successful, silently discarding the schedule tick. raise RuntimeError('Missing required environment variables') bucket = storage_client.bucket(GCS_BUCKET) state = load_state(bucket, STATE_KEY) now = datetime.now(timezone.utc) watermark = None if state.get('last_event_time'): watermark = parse_datetime(state['last_event_time']) seen_keys = set(state.get('seen_keys', [])) if watermark is None: start_time = now - timedelta(hours=LOOKBACK_HOURS) else: start_time = watermark - timedelta(minutes=OVERLAP_MINUTES) print(f"Fetching audit logs from {start_time.isoformat()} to {now.isoformat()}") token = get_token(CLIENT_ID, CLIENT_SECRET) # A FetchError here propagates: the run fails, the watermark is untouched, # and the next invocation retries the same window. records, newest_event_time = fetch_audit( org_id=MULE_ORG_ID, token=token, start_time=start_time, end_time=now, page_size=PAGE_SIZE, max_records=MAX_RECORDS, ) # Remove events already written by an earlier run. Without this, the overlap # window re-emits its events on every invocation. fresh = [r for r in records if not (event_keys(r) & seen_keys)] print(f"Fetched {len(records)} records, {len(fresh)} new after deduplication") if not fresh: print("No new audit log records found. Watermark left unchanged.") return if not newest_event_time: # A watermark that cannot be derived from the data is not a watermark. # Advancing on wall-clock here would skip every late-recorded event. raise FetchError('Records were returned but no event timestamp could be parsed') timestamp = now.strftime('%Y%m%dT%H%M%SZ') object_key = f"{GCS_PREFIX}/audit_{timestamp}.ndjson" blob = bucket.blob(object_key) ndjson = '\n'.join(json.dumps(record, ensure_ascii=False) for record in fresh) + '\n' blob.upload_from_string(ndjson, content_type='application/x-ndjson') print(f"Wrote {len(fresh)} records to gs://{GCS_BUCKET}/{object_key}") # Advance the watermark only after the data is durably written. new_watermark = parse_datetime(newest_event_time) if watermark and new_watermark < watermark: new_watermark = watermark # Retain only the hashes still inside the overlap window, so the state file # stays small while covering every event the next query can re-read. cutoff = new_watermark - timedelta(minutes=OVERLAP_MINUTES) retained = set() for record in records: stamp = event_time(record) if not stamp: continue if parse_datetime(stamp) >= cutoff: retained.update(event_keys(record)) save_state(bucket, STATE_KEY, { 'last_event_time': new_watermark.isoformat(), 'seen_keys': sorted(retained), }) print(f"Successfully processed {len(fresh)} records") def load_state(bucket, key): """Read the collector state from Cloud Storage. Only a missing object is treated as a cold start. Any other error is raised: swallowing it would silently reset collection to the full lookback window and re-ingest that entire period. """ blob = bucket.blob(key) try: return json.loads(blob.download_as_text()) except NotFound: print('No state file found. Starting from the lookback window.') return {} def save_state(bucket, key, state: dict): """Write the collector state to Cloud Storage. Failures are raised, not logged. If the state write fails after the data was uploaded, the next run repeats the same window and duplicates it. """ blob = bucket.blob(key) blob.upload_from_string( json.dumps(state, indent=2), content_type='application/json', ) print(f"Saved state: last_event_time={state.get('last_event_time')}") def retry_after_seconds(response, backoff: float) -> int: """Parse Retry-After defensively; it may be an HTTP date, which int() cannot parse.""" raw = response.headers.get('Retry-After') try: return int(raw) if raw else int(backoff) except (TypeError, ValueError): return int(backoff) def get_token(client_id: str, client_secret: str) -> str: """Get an OAuth 2.0 access token from MuleSoft using client credentials.""" body = urlencode({ 'grant_type': 'client_credentials', 'client_id': client_id, 'client_secret': client_secret, }).encode('utf-8') backoff = 1.0 for attempt in range(5): try: response = http.request( 'POST', TOKEN_URL, body=body, headers={'Content-Type': 'application/x-www-form-urlencoded'}, ) except Exception as e: raise FetchError(f'Token request failed: {e}') from e if response.status == 429: wait = retry_after_seconds(response, backoff) print(f"Rate limited (429) on token request. Retrying after {wait}s...") time.sleep(wait) backoff = min(backoff * 2, 30.0) continue if response.status != 200: raise FetchError(f'Failed to get token: HTTP {response.status} {response.data.decode("utf-8", "replace")}') try: return json.loads(response.data.decode('utf-8'))['access_token'] except (json.JSONDecodeError, KeyError) as e: raise FetchError(f'Malformed token response: {e}') from e raise FetchError('Rate limited repeatedly on token request; giving up without advancing the watermark') def fetch_audit(org_id: str, token: str, start_time: datetime, end_time: datetime, page_size: int, max_records: int): """Fetch audit log entries from the MuleSoft Audit Log Query API. Uses cursor pagination. The API returns entries in ascending timestamp order, which a time watermark requires: with newest-first ordering a truncated run would advance the watermark past unread events. Returns: Tuple of (records list, newest_event_time ISO string). Raises: FetchError: on any API or transport failure, so that the caller does not mistake a failed fetch for an empty result and advance the watermark. """ endpoint = AUDIT_URL.format(org_id=org_id) headers = { 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', 'Accept': 'application/json', } records = [] newest_time = None cursor = None page_num = 0 backoff = 1.0 rate_limit_retries = 0 MAX_RATE_LIMIT_RETRIES = 5 while True: page_num += 1 if len(records) >= max_records: # Stop cleanly. The remaining events stay ahead of the watermark and # are collected by the next run. print(f"Reached max_records limit ({max_records})") break # Rebuild the full query on every page; the time window must never be dropped. body = { 'startDate': to_unix_millis(start_time), 'endDate': to_unix_millis(end_time), 'limit': page_size, 'ascending': True, } if cursor: body['cursor'] = cursor try: response = http.request( 'POST', endpoint, body=json.dumps(body).encode('utf-8'), headers=headers, ) except Exception as e: raise FetchError(f'Request to {endpoint} failed: {e}') from e if response.status == 429: rate_limit_retries += 1 if rate_limit_retries > MAX_RATE_LIMIT_RETRIES: raise FetchError('Rate limited repeatedly; giving up without advancing the watermark') wait = retry_after_seconds(response, backoff) print(f"Rate limited (429). Retrying after {wait}s...") time.sleep(wait) backoff = min(backoff * 2, 30.0) continue backoff = 1.0 rate_limit_retries = 0 if response.status != 200: raise FetchError(f'HTTP {response.status} from {endpoint}: {response.data.decode("utf-8", "replace")}') try: data = json.loads(response.data.decode('utf-8')) except json.JSONDecodeError as e: raise FetchError(f'Malformed JSON response from {endpoint}: {e}') from e page_results = data.get('data') or [] if not page_results: print("No more results (empty page)") break print(f"Page {page_num}: Retrieved {len(page_results)} entries") records.extend(page_results) for event in page_results: stamp = event_time(event) if not stamp: continue if newest_time is None or parse_datetime(stamp) > parse_datetime(newest_time): newest_time = stamp cursor = data.get('cursor') if not cursor: break print(f"Retrieved {len(records)} total entries from {page_num} pages") return records, newest_timeSecond file - requirements.txt:
functions-framework==3.* google-cloud-storage==2.* urllib3>=2.0.0
Click Deploy to save and deploy the function.
Wait for deployment to complete (2-3 minutes).
Important considerations
Rate limiting: The Audit Log Query endpoint applies rate limits per IP in the three control planes. The US control plane allows 700 requests per minute per IP, while EU and Gov control planes allow 40 requests per minute per IP. The function implements exponential backoff to handle rate limiting automatically.
Token expiration: Access tokens usually expire about 30 to 60 minutes after being issued. The function requests a new token for each execution, which is well within that window.
Deduplication and state: The function keeps a watermark of the newest event time and the hashes of events inside the overlap window in the state file. Each run re-reads two minutes before the watermark to catch late-recorded entries and drops the ones it already wrote, so a retry, a manual run, or a scheduler delay does not produce duplicate events. A failed API call fails the run and leaves the watermark unchanged, so the next run retries the same window.
Audit log retention: Audit logs have a default retention period of one year. If your organization was created before July 10, 2023 and you didn't manually change the retention period, the retention period is six years. Download logs periodically if you need to retain them beyond the configured retention period.
Create a Cloud Scheduler job
Cloud Scheduler will publish messages to the Pub/Sub topic at regular intervals, triggering the Cloud Run function.
- In the GCP Console, go to Cloud Scheduler.
- Click Create Job.
Provide the following configuration details:
Setting Value Name mulesoft-audit-collector-hourlyRegion Select same region as Cloud Run function Frequency 0 * * * *(every hour, on the hour)Timezone Select timezone (UTC recommended) Target type Pub/Sub Topic Select the topic mulesoft-audit-triggerMessage body {}(empty JSON object)Click Create.
Test the scheduler job
- In the Cloud Scheduler console, find your job.
- Click Force run to trigger manually.
- Wait a few seconds and go to Cloud Run > Services > mulesoft-audit-collector > Logs.
- Verify the function executed successfully.
- Check the Cloud Storage bucket to confirm logs were written.
Configure a feed in Google SecOps to ingest the MuleSoft logs
- Go to SIEM Settings > Feeds.
- Click Add New Feed.
- Click Configure a single feed.
- In the Feed name field, enter a name for the feed (for example,
MuleSoft Logs). - Select Google Cloud Storage V2 as the Source type.
- Select Mulesoft as the Log type.
Click Get Service Account. A unique service account email will be displayed, for example:
chronicle-12345678@chronicle-gcp-prod.iam.gserviceaccount.comCopy this email address. You will use it in the next step.
Click Next.
Specify values for the following input parameters:
Storage bucket URL: Enter the Cloud Storage bucket URI:
gs://mulesoft-audit-logs/- Replace
mulesoft-audit-logswith the actual name of the bucket.
- Replace
Source deletion option: Select the deletion option according to your preference:
- Never delete files: Never delete files from the source (recommended for testing).
- Delete transferred files and empty directories: Delete files and empty directories from the source after a successful fetch completes.
Maximum File Age: Include files modified in the last number of days. Default is 180 days.
Asset namespace: The asset namespace.
Ingestion labels: The label to be applied to the events from this feed.
Click Next.
Review your new feed configuration in the Finalize screen, and then click Submit.
Grant IAM permissions to the Google SecOps service account
The Google SecOps service account needs two roles on your Cloud Storage bucket: Storage Object Viewer to read the log objects, and a bucket-level role to read the bucket metadata.
- Go to Cloud Storage > Buckets.
- Click your bucket name.
- Go to the Permissions tab.
- Click Grant access.
- Provide the following configuration details:
- Add principals: Paste the Google SecOps service account email.
- Assign roles: Select both of the following:
- Storage Object Viewer: reads the log objects.
- Storage Legacy Bucket Reader: reads the bucket metadata. If you selected the Delete transferred files and empty directories deletion option, select Storage Legacy Bucket Writer instead, which also grants the delete permission.
Click Save.
Need more help? Get answers from Community members and Google SecOps professionals.