UnlockOS Developers
← 記事一覧に戻る
🔐

単一の監査ライター、デフォルト拒否RLS、ステップアップ認証

2026年10月26日→2026年11月1日
9 分
166 commits
深度 8/10
securityaudit-loggingpostgresqltypescriptreliability

単一の監査ライター、デフォルト拒否RLS、ステップアップ認証

はじめに

スマートロックのプラットフォームは、ドアがくっついただけのCRUDアプリではありません。あらゆる書き込みが、その前に立っているべき人(あるいは立っているべきでない人)のために物理的なドアを開くという結果に変換され得ます。直近の開発サイクルでは、コミットの大部分が機能開発ではありませんでした。それらは信頼できる経路を唯一の経路にするための作業でした。

本記事では、その作業から生まれた5つのパターンを紹介します。

  1. 単一の監査ライター。「ログは取っています」を習慣ではなく構造的な保証にします。
  2. デフォルト拒否の行レベルセキュリティ。施設スコープを読み取りだけでなく書き込みでも強制します。
  3. 特権コンソール向けのステップアップ認証とフレーム防止ヘッダー。
  4. サンドボックス向けのケイパビリティゲーティング。機械検証されるガード台帳に裏付けられます。
  5. 解決済みの単一設定オブジェクト。あるエンティティに関するすべてのポリシー判断が同じソースから来るようにします。

例はすべて一般化してあります。アクセス制御の比重が大きいあらゆるシステムに持ち込めるパターンです。


1. すべての監査イベントに単一のライターを

監査ログで最もよくある失敗は、テーブルが存在しないことではありません。ドリフトです。呼び出し箇所が12個、形が9種類、そのうち3つは静かにエラーを飲み込み、そして本当に重要だった唯一の操作——拒否——は、コードが早期リターンしたために一度も記録されていない、という状態です。

私たちは、あらゆる監査経路(リモート解錠、フロントデスク業務、返金、手動領収書、連携設定の変更、エクスポートジョブ、鍵リンク)を単一スキーマの単一ライターに集約しました。

export type AuditOutcome = 'succeeded' | 'refused' | 'failed';
export interface AuditEvent {
  /** Stable verb: 'lock.remote_unlock', 'payment.refund', 'integration.update' */
  action: string;
  outcome: AuditOutcome;
  /** Who acted. Service-role callers must declare themselves explicitly. */
  actor: { type: 'user' | 'service' | 'guest'; id: string | null; email?: string };
  /** What was acted upon, always tenant-scoped. */
  target: { facilityId: string; resource: string; resourceId: string | null };
  /** Why a refusal happened — the most valuable field in the record. */
  reason?: string;
  /** Non-PII context only. Validated before write. */
  metadata?: Record<string, string | number | boolean | null>;
  occurredAt: string;
}

ライター自体の仕事は3つです。検証し、永続化し、転送する——そしてリクエスト失敗の原因には決してならないことです。

export async function recordAuditEvent(event: AuditEvent): Promise<void> {
  const parsed = auditEventSchema.safeParse(event);
  if (!parsed.success) {
    // A malformed audit record is a bug, not a user-facing error.
    logger.error('audit.schema_violation', { issues: parsed.error.issues });
    return;
  }
  const row = parsed.data;
  const results = await Promise.allSettled([
    auditStore.insert(row),
    logDrain.send({ ...row, level: row.outcome === 'succeeded' ? 'info' : 'warn' }),
  ]);
  for (const result of results) {
    if (result.status === 'rejected') {
      logger.error('audit.sink_failed', { action: row.action, error: String(result.reason) });
    }
  }
}

特筆すべき設計上の決定が3つあります。

拒否はファーストクラスの存在です。 reason を伴う outcome: 'refused' があってはじめて、「権限のないドアを誰かが繰り返し解錠しようとしたか?」に答えられます。成功だけが入ったログは監査証跡ではなく、ただのレシートです。

2つのシンクを独立して決着させます。 永続テーブルが記録の正本であり、ログドレインがアラートと保持期間を担います。Promise.allSettled により、不安定なオブザーバビリティベンダーが解錠リクエストを落とすことはなく、データベースが一時的に不調でもドレイン側に痕跡が残ります。

監査は呼び出し元に例外を投げません。 このライターは、コードベースの中でベストエフォートであることを許された唯一の場所です。それはまさに、他のすべての呼び出し箇所がこのライターを使うことを保証されたからこそ成立します。

監査パイプラインを観測可能にする

沈黙した監査パイプラインは、無いよりも悪いものです。誤った安心感を生むからです。私たちは、プロセスが生きているかどうかだけでなく、パイプラインが構成済みであることを監視側が断言できるよう、スコープ付きのヘルスプローブを公開しました。

export async function statusHealth(scope: string | null) {
  if (scope === 'audit') {
    const checks = {
      storeReachable: await auditStore.ping(),
      drainTokenPresent: Boolean(process.env.AUDIT_DRAIN_TOKEN),
      drainReachable: await logDrain.ping(),
    };
    const ok = Object.values(checks).every(Boolean);
    return { status: ok ? 'ok' : 'degraded', checks };
  }
  return { status: 'ok' };
}

返していないものに注目してください。シークレットの値も、トークンの一部も返しません。存在有無はboolean です。


2. デフォルト拒否のRLS——そして書き込みでも強制する

行レベルセキュリティは読み取りについては広く採用されている一方、書き込みについては仕様が不十分なまま使われがちです。USING 句はどの行を見られるかをフィルタするだけで、作成や移動が許される行を制約するのは WITH CHECK だけです。私たちはこのギャップのいくつかの変種を発見し、塞ぎました。

パターンA: テナント列を無視する書き込みポリシー

-- BROKEN: any authenticated user can insert a row for ANY facility.
create policy plans_insert on plans
  for insert to authenticated
  with check (true);
-- FIXED: the inserted row must belong to a facility the caller is staff of.
drop policy plans_insert on plans;
create policy plans_insert on plans
  for insert to authenticated
  with check (
    exists (
      select 1 from facility_members fm
      where fm.facility_id = plans.facility_id
        and fm.user_id = auth.uid()
        and fm.role in ('owner', 'staff')
    )
  );

この「ブラインドinsert」が特に危険なのは、攻撃者が何も読み取る必要がないからです。他人のテナントに行を書き込み、その後は正規のフローを通じてそれを操作できてしまいます。

パターンB: 認証情報テーブルへのクライアント書き込み

APIキーのテーブルは、ブラウザのセッショントークンから書き込み可能であってはなりません。テナントAのサインイン済みユーザーがテナントBの facility_id で insert into api_keys できるなら、テナンシーモデル全体が飾りにすぎません。

revoke insert, update, delete on api_keys from authenticated, anon;
alter table api_keys enable row level security;
alter table api_keys force row level security;
-- Reads: metadata only, own tenant only. Hash/secret columns live in a separate
-- table or are exposed exclusively through a view.
create policy api_keys_select on api_keys
  for select to authenticated
  using (facility_id = any (current_staff_facility_ids()));

発行は現在、認可チェックおよび監査書き込みを経たうえで、昇格権限で動作するサーバー関数の中だけで行われます。

パターンC: 曖昧なヘルパーのオーバーロード

ヘルパー関数を呼び出すポリシーは、その関数の正しさをそのまま引き継ぎます。is_platform_admin() 的なヘルパーの、引数なしの古いオーバーロードが正しい1引数版の隣に残っているのは、現在進行形の危険です。PostgreSQLが呼び出しを解決した結果、あなたが思っている定義とは別のものが使われているかもしれません。

-- Drop the ambiguous overload, then rebind every policy that referenced it.
drop function if exists is_platform_admin();
-- Re-create dependent policies against the explicit, parameterized definition.
create policy check_ins_admin_read on check_ins
  for select to authenticated
  using (is_platform_admin(auth.uid()) or facility_id = any (current_staff_facility_ids()));

パターンD: 集計が行ポリシーの保護対象を漏らす

課金やKPIの集計は、ビューやマテリアライズドビューとして公開されることが多く、ビューは一般にベーステーブルのRLSをバイパスします。これらを anon と authenticated に対して閉じ、認可済みのサーバーエンドポイント経由でのみ提供するのが、最もシンプルで正しい答えです。

revoke all on billing_totals_mv from anon, authenticated;
revoke all on kpi_daily_mv from anon, authenticated;

パターンE: 宙ぶらりんのリンクではなくライブなクレームで権限を区切る

物理アクセスにおいて、認可は時間で区切られていなければなりません。ゲストが部屋を解錠できる根拠は「予約があること」ではなく、「この施設について、今まさに滞在期間が有効な予約があること」です。

export function canRemoteUnlock(ctx: UnlockContext, now: Date): AuthzDecision {
  const { reservation, lock } = ctx;
  if (reservation.facilityId !== lock.facilityId) {
    return { allowed: false, reason: 'cross_facility_link' };
  }
  if (reservation.status === 'refunded' || reservation.status === 'cancelled') {
    return { allowed: false, reason: `reservation_${reservation.status}` };
  }
  const start = reservation.checkedInAt ?? reservation.startsAt;
  const end = reservation.checkedOutAt ?? reservation.bookedEndsAt;
  if (now < start || now >= end) {
    return { allowed: false, reason: 'outside_live_stay' };
  }
  return { allowed: true };
}

すべての分岐が機械可読な reason を返し、それがそのまま outcome: 'refused' として recordAuditEvent に流れます。リンクされていないレコードやレガシーなレコードには、無期限として扱う代わりに明示的な上限(bookedEndsAt)を与えます。期限のないアクセス付与こそが、「一時的」が「永久」に変わる仕組みだからです。


3. 管理画面向けのステップアップ認証とフレーム防止

セッション長はトレードオフです。長いセッションは便利ですが、長い特権セッションは負債です。答えは全員のセッションを短くすることではなく、ユーザーが特権領域に踏み込んだときに第2要素を再検証することです。

const STEP_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
export function requiresStepUp(session: Session, now: number): boolean {
  if (!session.isPlatformAdmin) return false;
  const verifiedAt = session.mfaVerifiedAt ?? 0;
  return now - verifiedAt > STEP_UP_WINDOW_MS;
}
export async function guardAdminRoute(req: Request): Promise<Response | null> {
  const session = await getSession(req);
  if (!session) return redirect('/login');
  if (requiresStepUp(session, Date.now())) {
    await recordAuditEvent({
      action: 'auth.step_up_required',
      outcome: 'refused',
      actor: { type: 'user', id: session.userId },
      target: { facilityId: session.facilityId, resource: 'admin_console', resourceId: null },
      reason: 'mfa_stale',
      occurredAt: new Date().toISOString(),
    });
    return redirect('/auth/verify?next=' + encodeURIComponent(req.url));
  }
  return null;
}

実運用では2つの細部が効いてきます。第一に、authenticatorのissuerラベルは実際の環境を反映していなければなりません。stagingと productionがどちらも「Acme」として登録されていると、運用者はproductionだと信じたままstagingのプロンプトを承認してしまいます。issuerはビルド時の推測ではなく、実際のAPIホストから導出してください。第二に、この時間ウィンドウに使う時計はサーバー側のものでなければなりません。

ステップアップと併せて、管理コンソールはフレーム化を一切拒否すべきです。「解錠」ボタンを含むコンソールに対するクリックジャッキングは、物理世界への攻撃です。

export const securityHeaders = {
  'Content-Security-Policy': "frame-ancestors 'none'",
  'X-Frame-Options': 'DENY',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'X-Content-Type-Options': 'nosniff',
  'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
};

frame-ancestors 'none' が現代的なディレクティブで、X-Frame-Options は古いエージェント向けに残します。


4. サンドボックスのケイパビリティゲーティングとガード台帳

デモ用・サンドボックス用のテナントは、典型的な影響範囲(ブラストレディウス)の問題です。見た目が本物のテナントと同じなので、誤って実メールを送ったり、有料APIを呼んだり、サードパーティ連携を叩いたりしかねません。こうした挙動を散在する if (isDemo) チェックで止めるのは脆弱です。新しいコード経路が増えるたびにチェックは腐っていきます。

代わりに、制約を明示的なケイパビリティとしてモデル化します。

export type Capability =
  | 'integration.external_call'
  | 'mail.send_arbitrary_recipient'
  | 'ai.paid_invocation'
  | 'search_engine.indexable';
export interface TenantPolicy {
  kind: 'production' | 'sandbox';
  denied: ReadonlySet<Capability>;
  limits: { paidAiCallsPerDay: number };
  mailAllowlist: readonly string[];
}
export function assertCapability(policy: TenantPolicy, cap: Capability): void {
  if (policy.denied.has(cap)) {
    throw new CapabilityRefused(cap, policy.kind);
  }
}

このパターンの後半こそ、多くのチームが省略しがちな部分です。ガード台帳——存在すべきすべてのガードをリポジトリにコミットしたレジストリとして持ち、テストで検証します。誰かがガードを削除したり、ガードなしで新しい外部連携を追加したりすれば、CIが失敗します。

const GUARD_LEDGER = [
  { capability: 'integration.external_call', module: 'integrations/dispatch.ts' },
  { capability: 'mail.send_arbitrary_recipient', module: 'mail/send.ts' },
  { capability: 'ai.paid_invocation', module: 'ai/invoke.ts' },
] as const;
test('every registered guard is still enforced', async () => {
  const policy = sandboxPolicy();
  for (const entry of GUARD_LEDGER) {
    const mod = await import(`../src/${entry.module}`);
    await expect(mod.run({ policy, payload: fixtureFor(entry.capability) }))
      .rejects.toBeInstanceOf(CapabilityRefused);
  }
});

これにより「ブロックするのを忘れずに覚えていた」が、ビルドによって強制される不変条件に変わります。同じ考え方は、外向きメール(見込み客1件のアドレスのみ許可リスト化)、インデックス(noindex に加えて閉じられない可視バナー)、支出上限にも拡張できます。


5. 判断ごとに解決済み設定を1つ

より微妙な種類のセキュリティバグがあります。ポリシーについて一致すべき2つのコード経路が、それぞれ別の計算をしているケースです。チェックインにおける「この予約は何を要求するのか?」という問いには、施設のデフォルト、入口レベルのオーバーライド、予約レベルのオーバーライド、機能フラグといった複数の入力があります。予約画面とキオスクがそれぞれ独自の優先順位ルールでこれらをマージすると、いずれ一方の画面では開き、もう一方では拒否されるドアが生まれます。

解決策は、1箇所で一度だけ解決し、すべての利用側が解決済みオブジェクトを読むようにすることです。

export interface ResolvedCheckinConfig {
  readonly requiresGuestRegister: boolean;
  readonly requiresMemberDoorQr: boolean;
  readonly paymentPolicy: 'prepaid' | 'on_site' | 'none';
  readonly windowOpensAt: Date;
  readonly windowClosesAt: Date;
  readonly staffedHours: StaffedHours | null;
  /** Provenance for every field, for debugging and audit. */
  readonly source: Readonly<Record<keyof Omit<ResolvedCheckinConfig, 'source'>, ConfigLayer>>;
}
type ConfigLayer = 'facility_default' | 'entrance' | 'reservation' | 'flag_override';
export function resolveCheckinConfig(input: ConfigInputs): ResolvedCheckinConfig {
  // Precedence is declared once: reservation > entrance > facility default.
  const layers = [input.facilityDefault, input.entrance, input.reservation];
  return freezeWithProvenance(layers, input.flags);
}

このリファクタリングを価値あるものにする性質が3つあります。

  • すべてが readonly。 下流のコードが変更できる解決済みポリシーオブジェクトは、ポリシーではなく提案にすぎません。境界で凍結することで、まるごと1クラス分のバグが型エラーに変わります。
  • フィールドごとの出所(provenance)。 運用者が「なぜこの入口はQRを要求したのか?」と尋ねたとき、source.requiresMemberDoorQr === 'entrance' が即座に答えになります。同じフィールドを監査メタデータに添付することもできます。
  • ありえない組み合わせを検証する場所が1つ。 例えば、前払い + 時間課金 + デポジットなしでは何も徴収できません。矛盾した構成を解決時に捕捉し、管理UIで警告として表示することで、売上とアクセスポリシーの静かな穴を防げます。

廃止済みや無効化されたサブリソースも、レンダリング時ではなく解決時にフィルタしなければなりません。廃止したサービスをUIが除外していてもAPIが受け付けるなら、それは隠れたエンドポイントを出荷したのと同じです。


6. 境界で安全に失敗する

信頼性の作業はセキュリティの作業でもあります。チェックアウト中にゲスト向けアプリがクラッシュすることは、悪い体験であると同時に、観測されない状態遷移でもあります。

クライアントが読み取り権限を失ったら、例外ではなく縮退を。 滞在情報をもう読めなくなったブラウザ(トークン期限切れ、セッションのローテーション)は、チェックアウトフローの内部で例外を投げるのではなく、縮小された明示ラベル付きのビューを返すべきです。

export async function loadStay(id: string): Promise<StayView> {
  const result = await stays.get(id);
  if (result.error?.code === 'PGRST116' || result.error?.code === '42501') {
    return { kind: 'unreadable', id, hint: 'session_scope_changed' };
  }
  if (result.error) throw new StayLoadError(result.error);
  return { kind: 'full', stay: result.data };
}

バックグラウンドタスクのrejectを捕捉する。 Service Workerの更新チェック、アナリティクスのビーコン、プリフェッチは、ありふれたネットワーク理由でrejectします。未処理のままだとエラートラッカーを溢れさせ、本物のインシデントを埋もれさせます。

void registration.update().catch((error) => {
  logger.debug('sw.update_failed', { message: String(error) });
});

すべてのエラーイベントにリリースタグを付ける。 リリースタグがないと、修正済みの課題が永遠に再オープンし、トリアージはシグナルを完全に失います。ソースマップはエラートラッカーにアップロードし、デプロイされるバンドルからは取り除く——そしてその不変条件を定期的に検証します。

#!/usr/bin/env bash
set -euo pipefail
for app in admin booking member checkin; do
  url="https://${app}.example.com"
  bundle=$(curl -fsS "$url" | grep -oE '/assets/[A-Za-z0-9_.-]+\.js' | head -n1)
  status=$(curl -s -o /dev/null -w '%{http_code}' "${url}${bundle}.map")
  if [ "$status" != "404" ]; then
    echo "FAIL: ${app} serves a source map (${status})" >&2
    exit 1
  fi
done
echo 'OK: no deployed app serves source maps'

デバイスごとにイベント数へ上限を。 レンダリングループに陥った1台のゲスト端末が、エラークォータを使い切って他のすべてを見えなくしてしまうことがあります。日次リセット付きのデバイス単位カウンタは、安価なサーキットブレーカーです。

ロールアウト状態をCIで守る。 機能フラグはアクセス制御の面です。私たちは、本番のフラグ状態をコミット済みの台帳と差分比較するスケジュールジョブと、本番でフラグがまだオフのコードを出荷しようとするリリースPRを失敗させるリリースゲートを運用しています。「フラグのないコード」「コードのないフラグ」という両方の故障モードが、人間が思い出さなければならなくなる前に検出されます。


まとめ

これらすべての変更に共通するのは、規律を構造で置き換えることです。

こうする代わりに こう作る
「ログを取るのを忘れずに」 拒否も含む固定スキーマの単一 recordAuditEvent ライター
「APIがテナントをチェックする」 すべての書き込みポリシーに WITH CHECK、加えて force row level security
「管理者のセッションは長い」 特権ルートでのステップアップMFA + frame-ancestors 'none'
「デモでは外部APIを呼ばない」 ケイパビリティ拒否 + CIで検証されるガード台帳
「両方の画面が同じポリシーを計算する」 凍結され出所情報を持つ単一の解決済み設定
「ソースマップはデプロイしていない」 大きな声で失敗する日次プローブ

どれも奇をてらった技術ではありません。これらが効果的なのは、どれもが慣習——レビュアーが気づかなければならない何か——を、型システムやデータベース、CIが代わりに強制してくれる不変条件へと変換しているからです。物理的なドアを開けるシステムにおいて、この違いこそがすべてです。

主要な発見

1
監査ログ

すべての監査イベントを、拒否も記録する単一のライターに集約する

固定スキーマと二重シンク(永続テーブル + ログドレイン)を備えた単一の recordAuditEvent 関数により、監査のカバレッジが構造的なものになります。機械可読な reason を伴う outcome:'refused' を記録することで、ログは単なるレシートから本物のセキュリティ証跡に変わり、ライターが呼び出し元に例外を投げることはありません。

2
セキュリティ

RLSは読み取りだけでなく書き込みも制約しなければならない

USING は可視性をフィルタするだけで、他テナントへのブラインドinsertを止められるのは WITH CHECK だけです。認証情報テーブルへのクライアント書き込みを閉じ、曖昧なヘルパーのオーバーロードを削除し、集計ビューの権限を anon/authenticated から剥奪することで、クロステナントの権限昇格をまるごと一掃できます。

3
認可

物理アクセスはライブで時間制限のあるクレームで区切る

リモート解錠には、同一施設で、返金・キャンセルされておらず、現在まさに有効な滞在ウィンドウ内にある予約を要求すべきです。リンクのないレコードやレガシーなレコードには明示的な上限を与え、一時的な付与が恒久化しないようにします。

4
認証

特権ルートでのステップアップMFAと frame-ancestors 'none'

すべてのセッションを短くするのではなく、プラットフォーム管理者が特権領域に踏み込んだ時点で第2要素を再検証し、クロスオリジンのフレーム化はきっぱり拒否します。解錠コンソールに対するクリックジャッキングは物理世界への攻撃だからです。

5
信頼性

ポリシーは一度だけ解決し、フィールドごとの出所を持つ凍結オブジェクトにする

複数の画面が施設・入口・予約のオーバーライドを独立にマージすると、いずれ食い違います。フィールドごとにソースレイヤーを持つ単一の readonly な解決済み設定が、一貫した判断、即座の説明可能性、そして矛盾した設定を却下する唯一の場所をもたらします。

6
テスト

安全ガードを台帳に記述し、CIで検証する

サンドボックスの制約、機能フラグのロールアウト状態、「ソースマップをデプロイしない」というルールは、いずれ人間が忘れる不変条件です。コミット済みのレジストリと定期チェックにより、それぞれをインシデントではなくビルド失敗に変換できます。