收集 Zendesk CRM 日志

支持的平台:

本文档介绍了如何使用 Google Cloud Storage 将 Zendesk 客户关系管理 (CRM) 日志提取到 Google Security Operations。Zendesk CRM 提供客户支持和工单管理功能。该平台通过审核日志和工单数据跟踪客户互动、支持服务工单和管理活动。

准备工作

确保您满足以下前提条件:

  • Google SecOps 实例
  • 已启用 Cloud Storage API 的 GCP 项目
  • 创建和管理 GCS 存储分区的权限
  • 管理 GCS 存储分区的 IAM 政策的权限
  • 创建 Cloud Run 函数、Pub/Sub 主题和 Cloud Scheduler 作业的权限
  • 对 Zendesk 的特权访问权限(需要管理员角色才能创建 API 令牌)
  • Zendesk Enterprise 方案(访问 Audit Logs API 所需的方案)

获取 Zendesk 前提条件

确认方案和角色

您必须是 Zendesk 管理员,才能创建 API 令牌或 OAuth 客户端。审核日志 API 仅适用于企业版方案,并且每页最多返回 100 条记录。如果您的账号不是企业账号,您仍然可以收集增量工单数据。

开启 API 令牌访问权限(一次性)

  1. 管理中心内,依次前往应用和集成 > API > Zendesk API
  2. 设置标签页中,启用令牌访问权限

生成 API 令牌(用于基本身份验证)

  1. 依次前往应用和集成 > API > Zendesk API
  2. 点击添加 API 令牌按钮。
  3. (可选)添加 API 令牌说明
  4. 点击创建
  5. 立即复制并保存 API 令牌(您将无法再次查看该令牌)。
  6. 保存将使用此令牌进行身份验证的管理电子邮件地址。

(可选)创建 OAuth 客户端(用于 Bearer 身份验证,而不是 API 令牌)

  1. 依次前往应用和集成 > API > Zendesk API
  2. 点击 OAuth 客户端标签页。
  3. 点击添加 OAuth 客户端
  4. 填写客户端名称唯一标识符(自动)、重定向网址(如果您仅通过 API 铸造令牌,则可以是占位网址)。
  5. 点击保存
  6. 为集成创建访问令牌,并授予本指南所需的最低范围:
    • tickets:read(对于增量工单)
    • auditlogs:read(适用于审核日志;仅限企业版)
  7. 复制访问令牌(粘贴到 ZENDESK_BEARER_TOKEN 环境变量中),并安全地记录客户端 ID/密钥(以用于未来的令牌刷新流程)。

记录您的 Zendesk 基本网址

使用 https://<your_subdomain>.zendesk.com(粘贴到 ZENDESK_BASE_URL 环境变量中)。

哪些内容可以保存以供日后处理

  • 基础网址(例如 https://acme.zendesk.com
  • 管理员用户的电子邮件地址(用于 API 令牌身份验证)
  • API 令牌(如果使用 AUTH_MODE=token)或 OAuth 访问令牌(如果使用 AUTH_MODE=bearer
  • (可选):用于生命周期管理的 OAuth 客户端 ID/密钥

创建 Google Cloud Storage 存储分区

  1. 前往 Google Cloud 控制台
  2. 选择您的项目或创建新项目。
  3. 在导航菜单中,依次前往 Cloud Storage > 存储分区
  4. 点击创建存储分区
  5. 提供以下配置详细信息:

    设置
    为存储分区命名 输入一个全局唯一的名称(例如 zendesk-crm-logs
    位置类型 根据您的需求进行选择(区域级、双区域级、多区域级)
    位置 选择相应位置(例如 us-central1
    存储类别 标准(建议用于经常访问的日志)
    访问权限控制 统一(推荐)
    保护工具 可选:启用对象版本控制或保留政策
  6. 点击创建

为 Cloud Run 函数创建服务账号

Cloud Run 函数需要一个服务账号,该账号具有向 GCS 存储分区写入内容以及被 Pub/Sub 调用的权限。

创建服务账号

  1. GCP 控制台中,依次前往 IAM 和管理 > 服务账号
  2. 点击创建服务账号
  3. 提供以下配置详细信息:
    • 服务账号名称:输入 zendesk-crm-collector-sa
    • 服务账号说明:输入 Service account for Cloud Run function to collect Zendesk CRM logs
  4. 点击创建并继续
  5. 向此服务账号授予对项目的访问权限部分中,添加以下角色:
    1. 点击选择角色
    2. 搜索并选择 Storage Object Admin
    3. 点击 + 添加其他角色
    4. 搜索并选择 Cloud Run Invoker
    5. 点击 + 添加其他角色
    6. 搜索并选择 Cloud Functions Invoker
  6. 点击继续
  7. 点击完成

必须拥有这些角色,才能:

  • Storage Object Admin:将日志写入 GCS 存储分区并管理状态文件
  • Cloud Run Invoker:允许 Pub/Sub 调用函数
  • Cloud Functions Invoker:允许调用函数

授予对 GCS 存储分区的 IAM 权限

向服务账号授予对 GCS 存储分区的写入权限:

  1. 前往 Cloud Storage > 存储分区
  2. 点击您的存储分区名称。
  3. 前往权限标签页。
  4. 点击授予访问权限
  5. 提供以下配置详细信息:
    • 添加主账号:输入服务账号电子邮件地址(例如 zendesk-crm-collector-sa@PROJECT_ID.iam.gserviceaccount.com)。
    • 分配角色:选择 Storage Object Admin
  6. 点击保存

创建发布/订阅主题

创建一个 Pub/Sub 主题,供 Cloud Scheduler 发布消息,并供 Cloud Run 函数订阅。

  1. GCP 控制台中,前往 Pub/Sub > 主题
  2. 点击创建主题
  3. 提供以下配置详细信息:
    • 主题 ID:输入 zendesk-crm-trigger
    • 将其他设置保留为默认值。
  4. 点击创建

创建 Cloud Run 函数以收集日志

Cloud Run 函数由来自 Cloud Scheduler 的 Pub/Sub 消息触发,用于从 Zendesk API 中提取日志并将其写入 GCS。

  1. GCP 控制台中,前往 Cloud Run
  2. 点击创建服务
  3. 选择函数(使用内嵌编辑器创建函数)。
  4. 配置部分中,提供以下配置详细信息:

    设置
    Service 名称 zendesk-crm-collector
    区域 选择与您的 GCS 存储分区匹配的区域(例如 us-central1
    运行时 选择 Python 3.12 或更高版本
  5. 触发器(可选)部分中:

    1. 点击 + 添加触发器
    2. 选择 Cloud Pub/Sub
    3. 选择 Cloud Pub/Sub 主题部分,选择主题 zendesk-crm-trigger
    4. 点击保存
  6. 身份验证部分中:

    1. 选择需要进行身份验证
    2. 检查 Identity and Access Management (IAM)
  7. 向下滚动并展开容器、网络、安全性

  8. 前往安全标签页:

    • 服务账号:选择服务账号 zendesk-crm-collector-sa
  9. 前往容器标签页:

    1. 点击变量和密钥
    2. 为每个环境变量点击 + 添加变量
    变量名称 示例值 说明
    GCS_BUCKET zendesk-crm-logs GCS 存储分区名称
    GCS_PREFIX zendesk/crm/ 日志文件的前缀
    STATE_KEY zendesk/crm/state.json 状态文件路径
    ZENDESK_BASE_URL https://your_subdomain.zendesk.com Zendesk 基本网址
    AUTH_MODE token 身份验证模式(tokenbearer
    ZENDESK_EMAIL analyst@example.com API 令牌身份验证的管理员电子邮件地址
    ZENDESK_API_TOKEN <api_token> 用于身份验证的 API 令牌
    ZENDESK_BEARER_TOKEN <leave empty unless using OAuth bearer> OAuth 不记名令牌(可选)
    RESOURCES audit_logs,incremental_tickets 可收集的资源
    MAX_PAGES 20 每次运行的页数上限
    LOOKBACK_SECONDS 3600 初始回溯期
    HTTP_TIMEOUT 60 HTTP 请求超时
    HTTP_RETRIES 3 HTTP 重试次数
  10. 变量和 Secret 部分中,向下滚动到请求

    • 请求超时:输入 600 秒(10 分钟)。
  11. 前往设置标签页:

    • 资源部分中:
      • 内存:选择 512 MiB 或更高值。
      • CPU:选择 1
  12. 修订版本扩缩部分中:

    • 实例数下限:输入 0
    • 实例数上限:输入 100(或根据预期负载进行调整)。
  13. 点击创建

  14. 等待服务创建完成(1-2 分钟)。

  15. 创建服务后,系统会自动打开内嵌代码编辑器

添加函数代码

  1. 函数入口点中输入 main
  2. 在内嵌代码编辑器中,创建两个文件:

    • 第一个文件:main.py:
    import functions_framework
    from google.cloud import storage
    import json
    import os
    import urllib3
    from datetime import datetime, timezone
    import base64
    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()
    
    @functions_framework.cloud_event
    def main(cloud_event):
        """
        Cloud Run function triggered by Pub/Sub to fetch logs from Zendesk API and write to GCS.
    
        Args:
            cloud_event: CloudEvent object containing Pub/Sub message
        """
    
        # Get environment variables
        bucket_name = os.environ.get('GCS_BUCKET')
        prefix = os.environ.get('GCS_PREFIX', 'zendesk/crm/')
        state_key = os.environ.get('STATE_KEY', 'zendesk/crm/state.json')
    
        base_url = os.environ.get('ZENDESK_BASE_URL', '').rstrip('/')
        auth_mode = os.environ.get('AUTH_MODE', 'token').lower()
        email = os.environ.get('ZENDESK_EMAIL', '')
        api_token = os.environ.get('ZENDESK_API_TOKEN', '')
        bearer = os.environ.get('ZENDESK_BEARER_TOKEN', '')
    
        resources = [r.strip() for r in os.environ.get('RESOURCES', 'audit_logs,incremental_tickets').split(',') if r.strip()]
        max_pages = int(os.environ.get('MAX_PAGES', '20'))
        lookback = int(os.environ.get('LOOKBACK_SECONDS', '3600'))
        http_timeout = int(os.environ.get('HTTP_TIMEOUT', '60'))
        http_retries = int(os.environ.get('HTTP_RETRIES', '3'))
    
        if not all([bucket_name, base_url]):
            print('Error: Missing required environment variables')
            return
    
        try:
            # Get GCS bucket
            bucket = storage_client.bucket(bucket_name)
    
            # Load state
            state = load_state(bucket, state_key)
    
            print(f'Processing resources: {resources}')
    
            summary = []
    
            if 'audit_logs' in resources:
                res = fetch_audit_logs(
                    bucket, prefix, state.get('audit_logs', {}),
                    base_url, auth_mode, email, api_token, bearer,
                    max_pages, http_timeout, http_retries
                )
                state['audit_logs'] = {'next_url': res.get('next_url')}
                summary.append(res)
    
            if 'incremental_tickets' in resources:
                res = fetch_incremental_tickets(
                    bucket, prefix, state.get('incremental_tickets', {}),
                    base_url, auth_mode, email, api_token, bearer,
                    max_pages, lookback, http_timeout, http_retries
                )
                state['incremental_tickets'] = {'cursor': res.get('cursor')}
                summary.append(res)
    
            # Save state
            save_state(bucket, state_key, state)
    
            print(f'Successfully processed logs: {summary}')
    
        except Exception as e:
            print(f'Error processing logs: {str(e)}')
            raise
    
    def get_headers(auth_mode, email, api_token, bearer):
        """Get authentication headers."""
        if auth_mode == 'bearer' and bearer:
            return {
                'Authorization': f'Bearer {bearer}',
                'Accept': 'application/json'
            }
        if auth_mode == 'token' and email and api_token:
            auth_string = f'{email}/token:{api_token}'
            auth_bytes = auth_string.encode('utf-8')
            token = base64.b64encode(auth_bytes).decode('utf-8')
            return {
                'Authorization': f'Basic {token}',
                'Accept': 'application/json'
            }
        raise RuntimeError('Invalid auth settings: provide token (EMAIL + API_TOKEN) or BEARER')
    
    def http_get_json(url, headers, timeout, retries):
        """Make HTTP GET request with retries and exponential backoff."""
        attempt = 0
        backoff = 1.0
        while True:
            try:
                response = http.request('GET', url, headers=headers, timeout=timeout)
                if response.status == 200:
                    return json.loads(response.data.decode('utf-8'))
                elif response.status in (429, 500, 502, 503, 504) and attempt < retries:
                    retry_after = int(response.headers.get('Retry-After', int(backoff)))
                    print(f'HTTP {response.status}: Retrying after {retry_after}s (attempt {attempt + 1}/{retries})')
                    time.sleep(max(1, retry_after))
                    backoff = min(backoff * 2, 30.0)
                    attempt += 1
                    continue
                else:
                    raise Exception(f'HTTP {response.status}: {response.data.decode("utf-8")}')
            except Exception as e:
                if attempt < retries:
                    print(f'Request error: {e}. Retrying after {int(backoff)}s (attempt {attempt + 1}/{retries})')
                    time.sleep(backoff)
                    backoff = min(backoff * 2, 30.0)
                    attempt += 1
                    continue
                raise
    
    def put_page(bucket, prefix, payload, resource):
        """Write page to GCS."""
        ts = datetime.now(timezone.utc)
        key = f'{prefix}{ts.strftime("%Y/%m/%d/%H%M%S")}-zendesk-{resource}.json'
        blob = bucket.blob(key)
        blob.upload_from_string(
            json.dumps(payload),
            content_type='application/json'
        )
        return key
    
    def fetch_audit_logs(bucket, prefix, state, base_url, auth_mode, email, api_token, bearer, max_pages, timeout, retries):
        """Fetch audit logs with pagination."""
        headers = get_headers(auth_mode, email, api_token, bearer)
        next_url = state.get('next_url') or f'{base_url}/api/v2/audit_logs.json'
    
        pages = 0
        written = 0
        last_next = None
    
        while pages < max_pages and next_url:
            data = http_get_json(next_url, headers, timeout, retries)
            put_page(bucket, prefix, data, 'audit_logs')
            written += len(data.get('audit_logs', []))
    
            # Use next_page for pagination
            last_next = data.get('next_page')
            next_url = last_next
            pages += 1
    
            print(f'Audit logs page {pages}: Retrieved {len(data.get("audit_logs", []))} records')
    
        return {
            'resource': 'audit_logs',
            'pages': pages,
            'written': written,
            'next_url': last_next
        }
    
    def fetch_incremental_tickets(bucket, prefix, state, base_url, auth_mode, email, api_token, bearer, max_pages, lookback, timeout, retries):
        """Fetch incremental tickets with cursor-based pagination."""
        headers = get_headers(auth_mode, email, api_token, bearer)
        cursor = state.get('cursor')
    
        if not cursor:
            start = int(time.time()) - lookback
            next_url = f'{base_url}/api/v2/incremental/tickets/cursor.json?start_time={start}'
        else:
            next_url = f'{base_url}/api/v2/incremental/tickets/cursor.json?cursor={cursor}'
    
        pages = 0
        written = 0
        last_cursor = None
    
        while pages < max_pages and next_url:
            data = http_get_json(next_url, headers, timeout, retries)
            put_page(bucket, prefix, data, 'incremental_tickets')
            written += len(data.get('tickets', []))
    
            # Extract cursor from after_cursor field
            last_cursor = data.get('after_cursor')
            if last_cursor:
                next_url = f'{base_url}/api/v2/incremental/tickets/cursor.json?cursor={last_cursor}'
            else:
                next_url = None
    
            pages += 1
    
            print(f'Incremental tickets page {pages}: Retrieved {len(data.get("tickets", []))} records')
    
        return {
            'resource': 'incremental_tickets',
            'pages': pages,
            'written': written,
            'cursor': last_cursor
        }
    
    def load_state(bucket, key):
        """Load state from GCS."""
        try:
            blob = bucket.blob(key)
            if blob.exists():
                state_data = blob.download_as_text()
                return json.loads(state_data)
        except Exception as e:
            print(f'Warning: Could not load state: {str(e)}')
        return {'audit_logs': {}, 'incremental_tickets': {}}
    
    def save_state(bucket, key, state):
        """Save state to GCS."""
        try:
            blob = bucket.blob(key)
            blob.upload_from_string(
                json.dumps(state),
                content_type='application/json'
            )
        except Exception as e:
            print(f'Warning: Could not save state: {str(e)}')
    
    • 第二个文件:requirements.txt:
    functions-framework==3.*
    google-cloud-storage==2.*
    urllib3>=2.0.0
    
  3. 点击部署以保存并部署该函数。

  4. 等待部署完成(2-3 分钟)。

创建 Cloud Scheduler 作业

Cloud Scheduler 会定期向 Pub/Sub 主题发布消息,从而触发 Cloud Run 函数。

  1. GCP Console 中,前往 Cloud Scheduler
  2. 点击创建作业
  3. 提供以下配置详细信息:

    设置
    名称 zendesk-crm-collector-hourly
    区域 选择与 Cloud Run 函数相同的区域
    频率 0 * * * *(每小时一次,在整点时)
    时区 选择时区(建议选择世界协调时间 [UTC])
    目标类型 Pub/Sub
    主题 选择主题 zendesk-crm-trigger
    消息正文 {}(空 JSON 对象)
  4. 点击创建

时间表频率选项

  • 根据日志量和延迟时间要求选择频次:

    频率 Cron 表达式 使用场景
    每隔 5 分钟 */5 * * * * 高容量、低延迟
    每隔 15 分钟 */15 * * * * 搜索量中等
    每小时 0 * * * * 标准(推荐)
    每 6 小时 0 */6 * * * 量小、批处理
    每天 0 0 * * * 历史数据收集

测试集成

  1. Cloud Scheduler 控制台中,找到您的作业。
  2. 点击强制运行以手动触发作业。
  3. 等待几秒钟。
  4. 前往 Cloud Run > 服务
  5. 点击函数名称 zendesk-crm-collector
  6. 点击日志标签页。
  7. 验证函数是否已成功执行。查找以下项:

    Processing resources: ['audit_logs', 'incremental_tickets']
    Audit logs page 1: Retrieved X records
    Incremental tickets page 1: Retrieved X records
    Successfully processed logs: [...]
    
  8. 前往 Cloud Storage > 存储分区

  9. 点击您的存储分区名称。

  10. 前往前缀文件夹 zendesk/crm/

  11. 验证是否已创建具有当前时间戳的新 .json 文件。

如果您在日志中看到错误,请执行以下操作:

  • HTTP 401:检查环境变量中的 API 凭据
  • HTTP 403:验证账号是否具有所需权限(管理员角色、企业版方案的审核日志)
  • HTTP 429:速率限制 - 函数将自动重试,并采用指数退避算法
  • 缺少环境变量:检查是否已设置所有必需的变量

检索 Google SecOps 服务账号

Google SecOps 使用唯一的服务账号从您的 GCS 存储分区中读取数据。您必须授予此服务账号对您的存储分区的访问权限。

获取服务账号电子邮件地址

  1. 依次前往 SIEM 设置 > Feed
  2. 点击添加新 Feed
  3. 点击配置单个 Feed
  4. Feed 名称字段中,输入 Feed 的名称(例如 Zendesk CRM logs)。
  5. 选择 Google Cloud Storage V2 作为来源类型
  6. 选择 Zendesk CRM 作为日志类型
  7. 点击获取服务账号。系统会显示一个唯一的服务账号电子邮件地址,例如:

    chronicle-12345678@chronicle-gcp-prod.iam.gserviceaccount.com
    
  8. 复制此电子邮件地址,以便在下一步中使用。

向 Google SecOps 服务账号授予 IAM 权限

Google SecOps 服务账号需要对您的 GCS 存储分区具有 Storage Object Viewer 角色。

  1. 前往 Cloud Storage > 存储分区
  2. 点击您的存储分区名称。
  3. 前往权限标签页。
  4. 点击授予访问权限
  5. 提供以下配置详细信息:
    • 添加主账号:粘贴 Google SecOps 服务账号电子邮件地址。
    • 分配角色:选择 Storage Object Viewer
  6. 点击保存

在 Google SecOps 中配置 Feed 以注入 Zendesk CRM 日志

  1. 依次前往 SIEM 设置 > Feed
  2. 点击添加新 Feed
  3. 点击配置单个 Feed
  4. Feed 名称字段中,输入 Feed 的名称(例如 Zendesk CRM logs)。
  5. 选择 Google Cloud Storage V2 作为来源类型
  6. 选择 Zendesk CRM 作为日志类型
  7. 点击下一步
  8. 为以下输入参数指定值:

    • 存储分区网址:输入带有前缀路径的 GCS 存储分区 URI:

      gs://zendesk-crm-logs/zendesk/crm/
      
        • zendesk-crm-logs:您的 GCS 存储分区名称。
        • zendesk/crm/:存储日志的前缀/文件夹路径。
    • 来源删除选项:根据您的偏好选择删除选项:

      • 永不:永不删除转移后的任何文件(建议用于测试)。
      • 删除已转移的文件:在成功转移后删除文件。
      • 删除已转移的文件和空目录:在成功转移后删除文件和空目录。

    • 文件存在时间上限:包含在过去指定天数内修改的文件。默认值为 180 天。

    • 资产命名空间资产命名空间

    • 注入标签:要应用于此 Feed 中事件的标签。

  9. 点击下一步

  10. 最终确定界面中查看新的 Feed 配置,然后点击提交

需要更多帮助?获得社区成员和 Google SecOps 专业人士的解答。