收集 Metabase 日志

支持的平台:

本文档介绍了如何使用 Google Cloud Storage V2 将 Metabase 日志注入到 Google Security Operations。

Metabase 是一款开源商业智能和分析平台,可让组织查询数据库、创建可视化图表和构建信息中心。在专业版和企业版方案中,Metabase 会在其应用数据库中存储使用情况分析和审核数据,包括活动日志、查看日志和查询执行记录。Cloud Run 函数会查询 Metabase API 以检索此数据,并将其写入 GCS 存储桶,以便 Google SecOps 提取数据。

准备工作

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

  • Google SecOps 实例
  • 启用了 Cloud Storage、Cloud Run、Pub/Sub 和 Cloud Scheduler API 的 GCP 项目
  • 创建和管理 GCS 存储分区的权限
  • 管理 GCS 存储分区的 IAM 政策的权限
  • 创建 Cloud Run 服务、Pub/Sub 主题和 Cloud Scheduler 作业的权限
  • Metabase Pro 或 Enterprise 方案(开源版或 Cloud Starter 版不提供使用情况分析功能)
  • Metabase 管理员权限,用于创建 API 密钥
  • 在 Metabase 中添加为数据源的 Metabase 应用数据库(通过 API 查询审核表时需要)

配置 Metabase API 访问权限

如需让 Google SecOps 检索使用情况分析数据,您需要在 Metabase 中创建 API 密钥,并将应用数据库添加为可查询的数据源。

创建 API 密钥

  1. 以管理员身份登录 Metabase 实例
  2. 点击右上角的齿轮图标
  3. 选择管理员设置
  4. 转到设置标签页。
  5. 点击左侧菜单中的身份验证标签页。
  6. 滚动到 API 密钥,然后点击管理
  7. 点击创建 API 密钥 按钮。
  8. 提供以下配置详细信息:
    • 密钥名称:输入一个描述性名称(例如 Google SecOps Integration
    • 群组:选择有权访问使用情况分析收集功能和应用数据库查询权限的群组
  9. 点击创建
  10. 复制并妥善保存 API 密钥。

将应用数据库添加为数据源

如需通过 API 查询审核表,您必须在 Metabase 中添加 Metabase 应用数据库作为数据源。

  1. 在 Metabase 中,点击右上角的齿轮图标
  2. 选择管理员设置
  3. 转到数据库标签页。
  4. 点击添加数据库
  5. 配置数据库连接:
    • 数据库类型:选择 PostgreSQLMySQL(取决于您的应用数据库)
    • 名称:输入 Metabase Application Database
    • 主机:输入应用数据库的主机名
    • 端口:输入数据库端口(默认值:PostgreSQL 为 5432,MySQL 为 3306
    • 数据库名称:输入应用数据库名称(例如 metabase
    • 用户名:输入对 audit_logview_logquery_execution 表具有 SELECT 权限的数据库用户
    • 密码:输入数据库密码
  6. 点击保存
  7. 保存后,请记下网址中的数据库 ID(例如,https://metabase.example.com/admin/databases/5 表示数据库 ID 为 5)。

验证权限

如需验证 API 密钥是否具有必需的权限,请执行以下操作:

  1. 以管理员身份登录 Metabase。
  2. 依次点击齿轮图标 > 管理设置 > 用户
  3. 找到分配给相应 API 密钥的群组。
  4. 验证群组是否具有对使用情况分析收集的访问权限。
  5. 依次前往管理设置 > 权限 > 数据
  6. 验证该群组是否对应用数据库具有不受限查询访问权限。

测试 API 访问权限

  • 在继续进行集成之前,请先测试您的凭据:

    # Replace with your actual values
    METABASE_URL="https://your-metabase-instance.com"
    API_KEY="mb_your_api_key_here"
    DATABASE_ID="5"
    
    # Test API key authentication
    curl -s -H "x-api-key: ${API_KEY}" \
      "${METABASE_URL}/api/database" \
      | python3 -m json.tool
    
    # Test querying the application database for audit_log records
    curl -s -X POST "${METABASE_URL}/api/dataset/json" \
      -H "x-api-key: ${API_KEY}" \
      -H "Content-Type: application/json" \
      -d "{\"database\": ${DATABASE_ID}, \"type\": \"native\", \"native\": {\"query\": \"SELECT * FROM audit_log ORDER BY id DESC LIMIT 5\", \"template-tags\": {}}, \"parameters\": []}" \
      | python3 -m json.tool
    

如果响应成功,则会返回一个包含近期审核日志记录的 JSON 数组。

创建 Google Cloud Storage 存储桶

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

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

为 Cloud Run 函数创建服务账号

Cloud Run 函数需要一个服务账号,该账号具有写入 GCS 存储桶的权限,并且可以由 Pub/Sub 调用。

创建服务账号

  1. GCP 控制台中,依次前往 IAM 和管理 > 服务账号
  2. 点击创建服务账号
  3. 提供以下配置详细信息:
    • 服务账号名称:输入 metabase-audit-collector-sa
    • 服务账号说明:输入 Service account for Cloud Run function to collect Metabase audit 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 权限

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

创建 Pub/Sub 主题

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

创建 Cloud Run 函数以收集日志

Cloud Run 函数将由来自 Cloud Scheduler 的 Pub/Sub 消息触发,以从 Metabase API 中提取审核数据并将其写入 GCS。

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

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

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

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

  8. 前往安全性标签页:

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

    1. 点击变量和密钥
    2. 为每个环境变量点击+ 添加变量
    变量名称 示例值 说明
    GCS_BUCKET metabase-audit-logs GCS 存储桶名称
    GCS_PREFIX metabase-audit 日志文件的前缀
    STATE_KEY metabase-audit/state.json 状态文件路径
    METABASE_URL https://metabase.yourcompany.com Metabase 实例基本网址
    METABASE_API_KEY mb_your_api_key_here Metabase API 密钥
    DATABASE_ID 5 应用数据库 ID
    LOOKBACK_HOURS 24 初始回溯期
    PAGE_SIZE 2000 每次 API 查询的记录数
  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, timedelta
      
      http = urllib3.PoolManager(
        timeout=urllib3.Timeout(connect=10.0, read=60.0),
        retries=False,
      )
      
      storage_client = storage.Client()
      
      GCS_BUCKET = os.environ.get('GCS_BUCKET')
      GCS_PREFIX = os.environ.get('GCS_PREFIX', 'metabase-audit')
      STATE_KEY = os.environ.get('STATE_KEY', 'metabase-audit/state.json')
      METABASE_URL = os.environ.get('METABASE_URL', '').rstrip('/')
      API_KEY = os.environ.get('METABASE_API_KEY')
      DATABASE_ID = int(os.environ.get('DATABASE_ID', '1'))
      LOOKBACK_HOURS = int(os.environ.get('LOOKBACK_HOURS', '24'))
      PAGE_SIZE = int(os.environ.get('PAGE_SIZE', '2000'))
      
      TABLES = ['audit_log', 'view_log', 'query_execution']
      
      TABLE_TIME_COLUMNS = {
        'audit_log': 'timestamp',
        'view_log': 'timestamp',
        'query_execution': 'started_at',
      }
      
      @functions_framework.cloud_event
      def main(cloud_event):
        if not all([GCS_BUCKET, METABASE_URL, API_KEY]):
          print('Error: Missing required environment variables')
          return
      
        try:
          bucket = storage_client.bucket(GCS_BUCKET)
          state = load_state(bucket)
          now = datetime.now(timezone.utc)
      
          if isinstance(state, dict) and state.get('last_event_time'):
            try:
              last_val = state['last_event_time']
              if last_val.endswith('Z'):
                last_val = last_val[:-1] + '+00:00'
              last_time = datetime.fromisoformat(last_val)
              last_time = last_time - timedelta(minutes=2)
            except Exception as e:
              print(f"Warning: Could not parse last_event_time: {e}")
              last_time = now - timedelta(hours=LOOKBACK_HOURS)
          else:
            last_time = now - timedelta(hours=LOOKBACK_HOURS)
      
          print(f"Fetching logs from {last_time.isoformat()} to {now.isoformat()}")
      
          all_records = []
          newest_time = None
      
          for table in TABLES:
            time_col = TABLE_TIME_COLUMNS[table]
            records = fetch_table(table, time_col, last_time, now)
            for r in records:
              r['_metabase_table'] = table
              t = r.get(time_col)
              if t and (newest_time is None or str(t) > str(newest_time)):
                newest_time = t
            all_records.extend(records)
            print(f"Table {table}: fetched {len(records)} records")
      
          if not all_records:
            print("No new records found.")
            save_state(bucket, now.isoformat())
            return
      
          timestamp = now.strftime('%Y%m%d_%H%M%S')
          object_key = f"{GCS_PREFIX}/metabase_audit_{timestamp}.ndjson"
          blob = bucket.blob(object_key)
      
          ndjson = '\n'.join(
            [json.dumps(r, ensure_ascii=False, default=str) for r in all_records]
          ) + '\n'
          blob.upload_from_string(ndjson, content_type='application/x-ndjson')
      
          print(f"Wrote {len(all_records)} records to gs://{GCS_BUCKET}/{object_key}")
      
          save_state(bucket, str(newest_time) if newest_time else now.isoformat())
      
          print(f"Successfully processed {len(all_records)} records")
      
        except Exception as e:
          print(f'Error processing logs: {str(e)}')
          raise
      
      def fetch_table(table, time_col, start_time, end_time):
        start_str = start_time.strftime('%Y-%m-%d %H:%M:%S')
        end_str = end_time.strftime('%Y-%m-%d %H:%M:%S')
      
        all_records = []
        offset = 0
        max_pages = 50
      
        for page in range(max_pages):
          sql = (
            f"SELECT * FROM {table} "
            f"WHERE {time_col} >= '{start_str}' "
            f"AND {time_col} < '{end_str}' "
            f"ORDER BY {time_col} ASC "
            f"LIMIT {PAGE_SIZE} OFFSET {offset}"
          )
      
          query_body = {
            "database": DATABASE_ID,
            "type": "native",
            "native": {
              "query": sql,
              "template-tags": {}
            },
            "parameters": []
          }
      
          url = f"{METABASE_URL}/api/dataset/json"
          response = http.request(
            'POST', url,
            body=json.dumps(query_body).encode('utf-8'),
            headers={
              'x-api-key': API_KEY,
              'Content-Type': 'application/json'
            }
          )
      
          if response.status == 429:
            print(f"Rate limited on {table} query. Stopping pagination.")
            break
      
          if response.status != 200:
            print(f"{table} query failed: {response.status} - "
                f"{response.data.decode('utf-8')}")
            break
      
          page_results = json.loads(response.data.decode('utf-8'))
      
          if not page_results:
            break
      
          all_records.extend(page_results)
          print(f"{table} page {page + 1}: {len(page_results)} records "
              f"(total: {len(all_records)})")
      
          if len(page_results) < PAGE_SIZE:
            break
      
          offset += PAGE_SIZE
      
        return all_records
      
      def load_state(bucket):
        try:
          blob = bucket.blob(STATE_KEY)
          if blob.exists():
            return json.loads(blob.download_as_text())
        except Exception as e:
          print(f"Warning: Could not load state: {e}")
        return {}
      
      def save_state(bucket, last_event_time_iso):
        try:
          state = {
            'last_event_time': last_event_time_iso,
            'last_run': datetime.now(timezone.utc).isoformat()
          }
          blob = bucket.blob(STATE_KEY)
          blob.upload_from_string(
            json.dumps(state, indent=2),
            content_type='application/json'
          )
          print(f"Saved state: last_event_time={last_event_time_iso}")
        except Exception as e:
          print(f"Warning: Could not save state: {e}")
      
    • requirements.txt:

    functions-framework==3.*
    google-cloud-storage==2.*
    urllib3>=2.0.0
    
  3. 点击部署以保存并部署该函数。

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

创建 Cloud Scheduler 作业

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

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

时间表频率选项

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

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

测试集成

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

    Fetching logs from YYYY-MM-DDTHH:MM:SS+00:00 to YYYY-MM-DDTHH:MM:SS+00:00
    Table audit_log: fetched X records
    Table view_log: fetched X records
    Table query_execution: fetched X records
    Wrote X records to gs://metabase-audit-logs/metabase-audit/metabase_audit_YYYYMMDD_HHMMSS.ndjson
    Successfully processed X records
    
  8. 前往 Cloud Storage > 存储分区

  9. 点击 metabase-audit-logs

  10. 转到 metabase-audit/ 文件夹。

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

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

  • HTTP 401:验证 METABASE_API_KEY 环境变量是否正确,以及密钥是否已被删除或重新生成
  • HTTP 403:验证 API 密钥的群组是否具有应用数据库的查询权限
  • HTTP 429:速率限制 - 函数将停止分页,并在下次预定运行时恢复
  • 缺少环境变量:验证是否已在 Cloud Run 函数配置中设置所有必需的变量
  • 结果为空:验证 DATABASE_ID 是否正确、应用数据库是否已添加为数据源,以及您是否在使用 Metabase Pro 或 Enterprise

检索 Google SecOps 服务账号

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

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

  9. 点击下一步

  10. 为以下输入参数指定值:

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

      gs://metabase-audit-logs/metabase-audit/
      
    • 来源删除选项:根据您的偏好选择删除选项:

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

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

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

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

  11. 点击下一步

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

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

Google SecOps 服务账号需要您的 GCS 存储桶的 Storage Object Viewer 角色。

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

使用情况分析表

Cloud Run 函数从 Metabase 应用数据库中查询以下表:

表名称 说明
audit_log 配置变更、用户操作和系统事件的记录
view_log 跟踪卡片(问题/模型)、信息中心和表的查看情况
query_execution 有关所有信息中心内执行的所有查询的信息

数据保留

默认情况下,Metabase 会将使用情况分析数据保留 720 天(大约 2 年)。Metabase 每天会自动删除两次超过此阈值的行。如需更改保留期限,请在运行 Metabase 时设置环境变量 MB_AUDIT_MAX_RETENTION_DAYS

UDM 映射表

日志字段 UDM 映射 逻辑
其他 其他 已从“其他”重命名
locale additional.fields 与相应标签合并(例如,locale_label 对应于 locale,is_active_label 对应于 is_active,等等)
is_active additional.fields
is_qbnewb additional.fields
group_ids additional.fields
is_superuser additional.fields
login_attributes additional.fields
id additional.fields
sso_source additional.fields
personal_collection_id additional.fields
元数据 元数据 已从元数据重命名
updated_at metadata.event_timestamp 使用 ISO8601、RFC 3339 或 yyyy-MM-ddTHH:mm:ss.SSSSSSZ 从 updated_at 解析出的日期
has_userid metadata.event_type 如果 has_userid == "true",则设置为“USER_UNCATEGORIZED”,否则设置为“GENERIC_EVENT”
主账号 主账号 已重命名为“principal”
metabase_host principal.url 直接复制值
date_joined principal.user.attribute.creation_time 使用 ISO8601、RFC 3339 或 yyyy-MM-ddTHH:mm:ss.SSSSSSZ 从 date_joined 解析出的日期
电子邮件 principal.user.email_addresses 根据电子邮件地址合并
first_name principal.user.first_name 直接复制值
last_login principal.user.last_login_time 使用 ISO8601、RFC 3339 或 yyyy-MM-ddTHH:mm:ss.SSSSSSZ 从 last_login 解析出的日期
last_name principal.user.last_name 直接复制值
common_name principal.user.user_display_name 直接复制值
电子邮件 principal.user.userid 直接从电子邮件中复制的值
security_result security_result 从 security_result 合并
目标 目标 已从目标重命名
metadata.product_name metadata.product_name 设置为“METABASE”
metadata.vendor_name metadata.vendor_name 设置为“METABASE”

更新日志

查看相应解析器的更改日志

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