😽

監査ログをプロダクト仕様に落とす:暗号資産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越しに行い、アプリに鍵素材を置かない。

  1. 二次保管と権限分離
    RDBの他にWORM相当ストレージ(S3 Object LockやGCSバケットロック等)へ不可変コピーを取るのが現実解。
    書込権限は専用SA、参照権限は分離。アプリ運用者が「消せてしまう」構造を許さない。

経路:DBトランザクション確定 → キュー(重複許容) → オブジェクトストレージに行単位で追記(例:NDJSON/1行=1イベント)

インデックス:監査用ビューは別DB/別プロジェクトにレプリカ(参照専用)

  1. 検証プロトコル(エビデンスの作り方)
    ログ本体だけでは監査は通りません。検証手順を一緒に渡すと早いです。

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
このスクリプト自体を成果物として監査に渡すと、確認が加速します。

  1. 監査ログと業務結果を結ぶ(トレーサビリティ)
    監査ログは業務結果へのリンクが命です。

送金確定イベント → 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から短時間の大量操作(閾値ルール)

鍵操作/リスクルール変更の監査トレイル(最重要)

  1. よくある落とし穴(アンチパターン)
    アプリログの延長でやる:削除やマスクが混じりがち。責務が違う。

JSON構造が不安定:カノニカル化せずにハッシュ→後で検証不能。

鍵をアプリに置く:署名の意味が薄れる。KMS/HSM越しに。

WORM無し:障害や人為的誤操作で消える。二系統で残す。

エビデンス出力が後回し:監査直前に慌てる。最初から仕様化。

  1. チェックリスト(配布用)
    Append-only(UPDATE/DELETE禁止)

連番・ギャップ検知

ハッシュ連鎖・署名・鍵ローテ

WORM相当二次保管・権限分離

エクスポート&検証スクリプト

業務結果との相互参照キー

監査ビューと欠番監視

ランブック(問い合わせ経路/責任分界)

おわりに
「数字で語る。証跡で支える。」を習慣にすると、監査は準備ではなく運用になります。小さく始め、検証を早く、学びを共有して仕組みに落とす。以上が私の実務メモの要点です。

https://ledgerfield.tokyo/

Discussion