📑

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

に公開

TL;DR

  • エンプラ導入で自動化が止まる理由の多くは証跡・権限・ロールバックの設計不足です。
  • まず監査ログのスキーマを決め、権限分離再試行/冪等性を最小構成で実装します。
  • 本稿は Slack → Cloud Functions → Salesforce API + BigQuery の参照実装と、審査パック(設計意図/運用手順/テスト証跡)の雛形を示します。
  • 小さく始め、TTV(Time to Value)≤15日を狙う運用ポイントを具体化します。

なぜ「監査ログから」始めるか

私は、業務自動化の品質は再現性説明責任で決まると考えます。
フロー図から着手すると都度例外に追われます。逆に監査ログの粒度を先に決めると、例外処理や権限設計が自明になります。
本稿では次の三原則を前提に進めます。

  1. 証跡先行:全イベントを一意キー・タイムスタンプ・呼出し元・結果で記録
  2. 権限分離読取・書込・運用の役割を分離(最小権限)
  3. 即時ロールバック:機能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は中長期で確認。

まとめ
数字で語る。小さく素早く。証跡で支える。
自動化は動くことより説明できることが価値になります。まずは監査ログから始めましょう。

https://signalstack.tokyo/

Discussion