監査ログをプロダクト仕様に落とす:暗号資産SaaSの設計実務

TL;DR
- 監査ログはアプリログの集合ではない。境界・責務・検証手順を最初に決める。
- 最小構成はAppend-only + 連番 + ハッシュ連鎖 + 署名。検証プロトコルまで同梱する。
- WORM相当の二次保管と権限分離で運用の穴を塞ぐ。エビデンス出力を最初から仕様化する。
0. 前提と用語
本稿の「監査ログ」は、説明責任(誰が・いつ・何を・どの権限で)を裏づける一次記録を指します。目的は後日の検証であり、運用観点では「改ざん耐性」「再現可能性」「可読なエビデンス化」を満たします。暗号資産SaaSを例にしますが、汎用のSaaSにも通用します。
- アプリログ:デバッグ/運用観測用途(可観測性)。削除・サンプリング前提のことも。
- 監査ログ:Append-onlyで保存、人が読む想定、検証可能が要件。
1. 監査要求から仕様へ:境界の切り方
要求をそのままログ項目に落とすと破綻します。先に境界を置きます。
- 対象アクション:認可境界を越えるもの(送金起票/承認/鍵操作/設定変更/仕訳確定など)
- 誰視点:エンドユーザ・社内オペレータ・システムアカウントを区別
- 整合基準:業務結果(例:ブロックチェーンTx/仕訳)と相互参照キーで結ぶ
- 保持方針:アプリログ≠監査ログ。保存系を分ける(権限・ライフサイクル)
2. データモデル(最小構成)
監査ログは「イベントソース + 検証情報」の合成物として定義します。
-- PostgreSQL想定(RDBでの最小形)
create table audit_events (
event_id uuid primary key,
workspace_id text not null,
seq bigint not null, -- ワークスペース単位の連番(ギャップ無しを保証)
happened_at timestamptz not null, -- 事象時刻(クライアント署名時刻でも可)
written_at timestamptz not null default now(),
actor_id text not null,
actor_role text not null, -- "requester" | "approver" | "system" 等
ip_addr inet,
user_agent text,
action text not null, -- "payment.request" 等(制御語彙)
resource_type text not null, -- "transfer" "wallet" "journal_entry" 等
resource_id text not null,
delta_json jsonb not null, -- 変更内容(カノニカル化前の原文も別テーブルで保持可)
prev_hash bytea not null, -- 直前イベントのcurr_hash
curr_hash bytea not null, -- prev_hash || canonical_event のハッシュ
signer_kid text not null, -- 署名鍵ID(KMSのバージョンを含む)
signature bytea not null, -- curr_hashに対する署名(HMACでもOKだが運用で検討)
unique(workspace_id, seq)
);}}
ポイント
seq(連番)にギャップ無し。欠番=異常の合図。トランザクションで滑らせない。
curr_hash = Hash(prev_hash || canonical(event))。カノニカル化(フィールド順序固定/小数・時刻正規化)は仕様に含める。
署名は鍵ローテーション前提(signer_kidで追跡)。鍵はKMS/外部HSMに隔離。
canonical(event) の例(擬似)
json
コードをコピーする
{
"workspace_id": "acme",
"seq": 42,
"happened_at": "2025-09-01T10:12:05Z",
"actor": {"id":"u_123","role":"approver"},
"action": "transfer.approve",
"resource": {"type":"transfer","id":"t_789"},
"delta": {"status_from":"requested","status_to":"approved","amount":"1.2345","asset":"BTC"}
}
3. ハッシュ連鎖と署名(実装パターン)
Append-onlyを守るには、計算規律と権限分離が必要です。
sql
コードをコピーする
-- 直前ハッシュを取得し、連番を割り当て、curr_hashを計算して挿入する擬似コード
-- アプリ層での一括トランザクションを推奨
WITH prev AS (
SELECT seq, curr_hash FROM audit_events
WHERE workspace_id = :ws ORDER BY seq DESC LIMIT 1
)
INSERT INTO audit_events (...)
VALUES (
gen_random_uuid(),
:ws,
COALESCE((SELECT seq FROM prev), 0) + 1,
:happened_at,
now(),
:actor_id,
:actor_role,
:ip_addr,
:ua,
:action,
:resource_type,
:resource_id,
:delta_json,
COALESCE((SELECT curr_hash FROM prev), decode(lpad('',64,'0'),'hex')),
digest( COALESCE((SELECT curr_hash FROM prev), '\x00'::bytea) || :canonical_bytes, 'sha256'),
:kid,
sign_with_kms(:kid, :curr_hash) -- アプリから外部呼び出し
);
署名方式
運用容易性重視ならHMAC(KMSの対称鍵)、非否認性重視なら非対称署名(ECDSA等)。
どちらにせよKMS越しに行い、アプリに鍵素材を置かない。
- 二次保管と権限分離
RDBの他にWORM相当ストレージ(S3 Object LockやGCSバケットロック等)へ不可変コピーを取るのが現実解。
書込権限は専用SA、参照権限は分離。アプリ運用者が「消せてしまう」構造を許さない。
経路:DBトランザクション確定 → キュー(重複許容) → オブジェクトストレージに行単位で追記(例:NDJSON/1行=1イベント)
インデックス:監査用ビューは別DB/別プロジェクトにレプリカ(参照専用)
- 検証プロトコル(エビデンスの作り方)
ログ本体だけでは監査は通りません。検証手順を一緒に渡すと早いです。
bash
コードをコピーする
1) エクスポート: 期間・workspaceでNDJSON書き出し(連番とハッシュ連鎖は連続していること)
2) チェック: prev_hash/curr_hashの整合、seqの連続、署名の検証
3) クロス: 事後結果(例:ブロックチェーンTx、仕訳ID)と相互参照キーで突合
検証スクリプト(擬似Python):
python
コードをコピーする
for ev in load_ndjson("export.ndjson"):
assert ev.seq == prev.seq + 1
assert ev.prev_hash == prev.curr_hash
assert ev.curr_hash == sha256(ev.prev_hash + canonical_bytes(ev))
assert verify_signature(ev.signature, ev.curr_hash, ev.signer_kid)
prev = ev
このスクリプト自体を成果物として監査に渡すと、確認が加速します。
- 監査ログと業務結果を結ぶ(トレーサビリティ)
監査ログは業務結果へのリンクが命です。
送金確定イベント → TxID/チェーン名/ブロック高をdelta_jsonに保持
仕訳確定イベント → journal_entry_idを保持(会計システム側の識別子)
承認イベント → 承認ルートIDとルールバージョンを保持(後日の規程改定に耐える)
シーケンス(Mermaid)
mermaid
コードをコピーする
sequenceDiagram
participant U as 申請者
participant S as SaaS
participant A as 承認者
participant C as チェーン
participant L as 監査ログ
U->>S: 送金を起票(amount,to)
S->>A: 承認依頼
A-->>S: 承認/却下
alt 承認
S->>C: ブロードキャスト(tx)
C-->>S: txid/ブロック高
S->>L: Append(transfer.approve/execute)
S->>L: Append(journal.fix) # 仕訳確定
else 却下
S->>L: Append(transfer.reject)
end
7. 運用:テーブル設計とビュー
正規テーブル:生ログ(消さない)。アプリからはINSERTのみ付与。UPDATE/DELETEはDBポリシーで禁止。
監査ビュー:人が読む列名に整形、delta_jsonから主要項目を派生列として抽出。
エクスポートビュー:期間・workspace絞り込み、ハッシュ検証済みフラグを付与。
sql
コードをコピーする
create view audit_readable as
select
workspace_id, seq, happened_at, actor_id, actor_role,
action, resource_type, resource_id,
delta_json->>'status_to' as status_to,
encode(curr_hash, 'hex') as curr_hash_hex
from audit_events;
8. 監査ダッシュボード(最小セット)
連番の欠番検知
アクションごとの頻度と役割分布
同一IPから短時間の大量操作(閾値ルール)
鍵操作/リスクルール変更の監査トレイル(最重要)
- よくある落とし穴(アンチパターン)
アプリログの延長でやる:削除やマスクが混じりがち。責務が違う。
JSON構造が不安定:カノニカル化せずにハッシュ→後で検証不能。
鍵をアプリに置く:署名の意味が薄れる。KMS/HSM越しに。
WORM無し:障害や人為的誤操作で消える。二系統で残す。
エビデンス出力が後回し:監査直前に慌てる。最初から仕様化。
- チェックリスト(配布用)
Append-only(UPDATE/DELETE禁止)
連番・ギャップ検知
ハッシュ連鎖・署名・鍵ローテ
WORM相当二次保管・権限分離
エクスポート&検証スクリプト
業務結果との相互参照キー
監査ビューと欠番監視
ランブック(問い合わせ経路/責任分界)
おわりに
「数字で語る。証跡で支える。」を習慣にすると、監査は準備ではなく運用になります。小さく始め、検証を早く、学びを共有して仕組みに落とす。以上が私の実務メモの要点です。
Discussion