審査に強い業務自動化の作り方──監査ログから逆算する最小実装

TL;DR
- エンプラ導入で自動化が止まる理由の多くは証跡・権限・ロールバックの設計不足です。
- まず監査ログのスキーマを決め、権限分離と再試行/冪等性を最小構成で実装します。
- 本稿は Slack → Cloud Functions → Salesforce API + BigQuery の参照実装と、審査パック(設計意図/運用手順/テスト証跡)の雛形を示します。
- 小さく始め、TTV(Time to Value)≤15日を狙う運用ポイントを具体化します。
なぜ「監査ログから」始めるか
私は、業務自動化の品質は再現性と説明責任で決まると考えます。
フロー図から着手すると都度例外に追われます。逆に監査ログの粒度を先に決めると、例外処理や権限設計が自明になります。
本稿では次の三原則を前提に進めます。
- 証跡先行:全イベントを一意キー・タイムスタンプ・呼出し元・結果で記録
- 権限分離:読取・書込・運用の役割を分離(最小権限)
- 即時ロールバック:機能ON/OFFと手戻しの手順書をデプロイと同時に用意
参照アーキテクチャ(スモールスタート)
Slack App (Events API)
│ (signed request)
▼
Google Cloud Functions (HTTP)
│ 1) verify signature
│ 2) enqueue & write audit_log
▼
BigQuery (audit_log dataset)
│
└─▶ Worker (CF/Scheduler)
│ call Salesforce/HubSpot API
│ write result to audit_log
▼
Salesforce/HubSpot/DB (least-privilege integration user)
pgsql
コードをコピーする
- 同期経路は最小に留め、外部API呼出しは非同期ワーカーに委譲
- 監査ログは**入口(受信)と出口(実行結果)**の両方を書く
- 機能トグルは環境変数とFeature Flagで即時OFF可能に
監査ログのスキーマ(BigQuery DDL)
最初に証跡の定義を固めます。以降のコードはこのスキーマに従います。
sql
-- dataset: ops_audit
CREATE TABLE IF NOT EXISTS ops_audit.audit_log (
event_id STRING NOT NULL, -- 送信元の一意ID(例: Slack event_id / 自前UUID)
source STRING NOT NULL, -- "slack" | "salesforce" | "hubspot" | etc.
actor STRING, -- 実行主体(ユーザー/ボット/サービスアカウント)
action STRING NOT NULL, -- "lead.create" | "case.update" | …
ts_receive TIMESTAMP NOT NULL, -- 受信時刻(UTC)
ts_process TIMESTAMP, -- 処理完了時刻(UTC)
trace_id STRING, -- 分散トレースID(任意)
request_payload JSON, -- 受信時の生データ(PIIはマスキング)
result_status STRING, -- "success" | "retry" | "failed" | "skipped"
result_detail JSON, -- 実行結果・レスポンス(要マスキング)
idempotency_key STRING, -- 冪等性キー(ハッシュ)
environment STRING, -- "dev" | "stg" | "prod"
version STRING, -- デプロイバージョン/commit hash
labels ARRAY<STRING> -- 任意タグ(ユースケース/顧客/機能)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_event
ON ops_audit.audit_log(event_id, source);
PII/秘密情報は保存前にマスキング。原則として必要最小限のみ保持
冪等性キーはsource + event_id + actionのハッシュを推奨
エッジの署名検証(Slack Events → CF)
Slack からのリクエストは署名検証とリプレイ防止が必須です。
以下は Google Cloud Functions (Node.js 20) の例です。
ts
コードをコピーする
// functions/src/http/slackEvents.ts
import crypto from "crypto";
import { Request, Response } from "express";
import { BigQuery } from "@google-cloud/bigquery";
const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET!;
const ENVIRONMENT = process.env.ENVIRONMENT ?? "dev";
const VERSION = process.env.VERSION ?? "local";
const bq = new BigQuery();
function verifySlackSignature(req: Request, bodyRaw: string): boolean {
const ts = req.header("x-slack-request-timestamp");
const sig = req.header("x-slack-signature");
if (!ts || !sig) return false;
// 5分超はリプレイとみなす
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 60 * 5) return false;
const base = v0:${ts}:${bodyRaw};
const hmac = crypto.createHmac("sha256", SLACK_SIGNING_SECRET).update(base).digest("hex");
const expected = v0=${hmac};
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
export const slackEvents = async (req: Request, res: Response) => {
const bodyRaw = (req as any).rawBody?.toString() ?? JSON.stringify(req.body);
if (!verifySlackSignature(req, bodyRaw)) {
return res.status(401).send("invalid signature");
}
const payload = JSON.parse(bodyRaw);
if (payload.type === "url_verification") {
// Slackのチャレンジ応答
return res.status(200).send(payload.challenge);
}
// 監査ログに受信を記録(入口)
const eventId = payload.event_id ?? crypto.randomUUID();
const idempotencyKey = crypto.createHash("sha256")
.update(slack:${eventId}:${payload.event?.type ?? "unknown"})
.digest("hex");
await bq.dataset("ops_audit").table("audit_log").insert([{
event_id: eventId,
source: "slack",
actor: payload.event?.user ?? "bot",
action: payload.event?.type ?? "unknown",
ts_receive: new Date().toISOString(),
request_payload: payload, // 実運用はマスキング関数を通す
result_status: "queued",
result_detail: { note: "accepted" },
idempotency_key: idempotencyKey,
environment: ENVIRONMENT,
version: VERSION,
labels: ["slack-events"]
}]);
// 非同期ワーカーに実処理を委譲(Pub/SubやSchedulerを推奨)
// ここでは即時200でSlackのリトライを防ぐ
return res.status(200).send("ok");
};
運用ポイント
rawBody を受け取り可能な設定にする(CF/Expressのミドルウェア設定)
URL Verification を忘れず実装
受信時点で監査ログに記録し、外部API呼出しは別ワーカーへ
外部API呼出し(Salesforce例・ワーカー)
ワーカーは冪等性と再試行(指数バックオフ)を持ちます。
ts
コードをコピーする
// functions/src/worker/sfLeadCreate.ts
import { BigQuery } from "@google-cloud/bigquery";
import crypto from "crypto";
import fetch from "node-fetch";
const bq = new BigQuery();
const SF_INSTANCE_URL = process.env.SF_INSTANCE_URL!;
const SF_CLIENT_ID = process.env.SF_CLIENT_ID!;
const SF_CLIENT_SECRET = process.env.SF_CLIENT_SECRET!;
const SF_USERNAME = process.env.SF_USERNAME!;
const SF_PASSWORD = process.env.SF_PASSWORD!; // + security token
const ENVIRONMENT = process.env.ENVIRONMENT ?? "dev";
const VERSION = process.env.VERSION ?? "local";
async function getSalesforceToken(): Promise<string> {
const r = await fetch(${SF_INSTANCE_URL}/services/oauth2/token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: SF_CLIENT_ID,
client_secret: SF_CLIENT_SECRET,
username: SF_USERNAME,
password: SF_PASSWORD
})
});
const j = await r.json();
if (!r.ok) throw new Error(SF token error: ${JSON.stringify(j)});
return j.access_token as string;
}
export async function processLeadCreate(event: any) {
const eventId = event.event_id ?? crypto.randomUUID();
const idempotencyKey = crypto.createHash("sha256")
.update(sf:lead.create:${eventId})
.digest("hex");
const token = await getSalesforceToken();
const res = await fetch(${SF_INSTANCE_URL}/services/data/v59.0/sobjects/Lead, {
method: "POST",
headers: {
"Authorization": Bearer ${token},
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey // API側が未対応でも自側で冪等化
},
body: JSON.stringify({
Company: event.company,
LastName: event.lastName,
Email: event.email,
LeadSource: "Slack"
})
});
const detail = await res.json();
const status = res.ok ? "success" : (res.status >= 500 ? "retry" : "failed");
await bq.dataset("ops_audit").table("audit_log").insert([{
event_id: eventId,
source: "salesforce",
actor: "integration_user",
action: "lead.create",
ts_receive: new Date().toISOString(),
ts_process: new Date().toISOString(),
request_payload: { fields: ["Company","LastName","Email"] },
result_status: status,
result_detail: detail,
idempotency_key: idempotencyKey,
environment: ENVIRONMENT,
version: VERSION,
labels: ["lead", "sf"]
}]);
if (!res.ok && res.status >= 500) {
throw new Error(retryable: ${res.status});
}
}
Terraform の最小構成(抜粋)
サービスアカウントは**実行(CF)とデータ書込(BQ)**を分離
付与権限はroles/bigquery.dataEditorなど最小に限定
hcl
コードをコピーする
providers.tf
terraform {
required_version = ">= 1.7.0"
required_providers {
google = { source = "hashicorp/google", version = "~> 5.0" }
}
}
provider "google" {
project = var.project_id
region = var.region
}
iam.tf
resource "google_service_account" "cf_runtime" {
account_id = "cf-runtime"
display_name = "Cloud Functions Runtime"
}
resource "google_service_account" "bq_writer" {
account_id = "bq-writer"
display_name = "BigQuery Writer"
}
resource "google_project_iam_member" "bq_writer_role" {
role = "roles/bigquery.dataEditor"
member = "serviceAccount:${google_service_account.bq_writer.email}"
}
bq.tf
resource "google_bigquery_dataset" "ops_audit" {
dataset_id = "ops_audit"
location = "US"
deletion_protection = true
}
実運用では環境別プロジェクト・フォルダ/ポリシー制御で境界を明確化します。
テスト戦略(審査で効く証跡)
署名検証テスト:改変ボディ/古いタイムスタンプで401を確認
冪等性テスト:同一event_idを複数回投入して1件のみ成功を確認
異常系:外部APIの429/5xxで指数バックオフ→再試行ログを確認
権限境界:SAキーが漏れても読取不可(BQのACL/Column-level security)
ロールバック:Feature Flag OFF→入口受信のみに戻ること
監査ログにテストイベントを残し、手順書とスクリーンショットを「審査パック」に同梱します。
運用ダッシュボード(最低限の可視化)
BigQuery のクエリ例(直近24時間の失敗率)。
sql
コードをコピーする
WITH last24 AS (
SELECT * FROM ops_audit.audit_log
WHERE ts_receive >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR)
)
SELECT
source, action,
COUNTIF(result_status = 'success') AS ok,
COUNTIF(result_status IN ('failed','retry')) AS ng,
SAFE_DIVIDE(COUNTIF(result_status IN ('failed','retry')),
COUNT(*)) AS error_rate
FROM last24
GROUP BY source, action
ORDER BY error_rate DESC;
定例レビューでは
エラー率 > 1% のユースケース
再試行の主因(レート制限/スキーマずれ)
PIIマスキングの逸脱(サンプル抽出)
を確認対象にします。
セキュリティ審査パック(雛形)
アーキテクチャ図(データ流通・保存先・暗号化)
権限表(SA/人間ユーザー/境界、付与理由)
監査ログスキーマ(項目・保持期間・マスキング方針)
運用手順(デプロイ/Feature Flag/ロールバック手順)
テスト証跡(上記5項目の実施記録)
インシデント対応(検知→初動→封じ込め→根本対策→報告)
これらをPull Requestのテンプレートに紐づけ、変更が審査パックへ自動反映される状態を保ちます。
導入スケジュール(TTV ≤ 15日の型)
Day 1–3:監査ログDDL、署名検証、入口ログの完成(運用ドキュメント初版)
Day 4–7:1ユースケースのワーカー実装(再試行/冪等性/マスキング)
Day 8–10:ダッシュボード雛形、審査パック初版、権限レビュー
Day 11–15:SLG(Service Level Goal)確定、運用移管、ロールバック演習
よくある質問(短答)
Q. まずどこから始める?
A. 監査ログDDLと署名検証。この2点で審査の土台ができます。
Q. ログに何を残せば十分?
A. 一意キー、時刻、呼出し元、結果、冪等性キー。PIIは最小限+マスキング。
Q. ロールバックはどの粒度?
A. 機能単位のFeature Flagと入口受信の停止**の2段階。データ復旧手順も同梱。
Q. どの指標を追う?
A. TTV、エラー率、再試行比率、成功までのレイテンシ。NRR/GRRは中長期で確認。
まとめ
数字で語る。小さく素早く。証跡で支える。
自動化は動くことより説明できることが価値になります。まずは監査ログから始めましょう。
Discussion