信頼境界:鍵の発行、RLS、ロールのライフサイクル
はじめに
スマートロックのプラットフォームにおいて、バグは見た目上の不具合では済みません。開いてはいけないドアが開いてしまう、あるいは23時に雨の中で立ち尽くすゲストの前でドアが開かない、という事態を意味します。今週のUnlockOS SDKの作業は、ひとつのテーマに収束しました。すなわち、すべての資格情報、すべての行、すべてのロールは、検証済みの状態から導出されなければならず、楽観的な想定に基づいてはならないということです。
本記事では、その作業の背後にあるパターンを解説します。状態でゲートされた資格情報の発行、データベースレベルのアクセス制御、ロールのライフサイクルの正しさ、そして「変更した内容について嘘をつかない」plan/apply/verifyループです。例は一般化してあります。APIコールが物理的な結果をもたらすあらゆるシステムに当てはまる内容です。
1. 状態を検証せずに資格情報を発行してはならない
アクセス制御における最も危険な近道は、システムの状態が許可しているからではなく、リクエストが妥当に見えたからという理由で鍵を発行してしまうことです。
この領域では2つの独立した修正が入りました。ゲスト鍵の発行はアクティブなチェックインに紐づいていなければならないこと、そして入室用の鍵はチェックインが**確定(confirmed)**する前(すなわち決済・与信が実際に成立する前)に発行されてはならないことです。どちらも根底にあるルールは同じです。資格情報とは状態機械の射影であり、独立したリソースではありません。
素朴な実装は次のようになります。
// ANTI-PATTERN: issuance derived from request shape, not system state
async function issueGuestKey(reservationId: string) {
const reservation = await db.reservations.findById(reservationId);
if (!reservation) throw new NotFoundError();
return lockApi.createPinCode({ lockId: reservation.lockId });
}これは予約IDを持っている人なら誰にでもアクセスを許可してしまいます。キャンセル済み、返金済み、未払い、あるいは既にチェックアウト済みの予約も含めてです。
修正版では、チェックインを明示的な有限状態としてモデル化し、発行をガードされた遷移にします。
export type CheckInState =
| 'pending'
| 'awaiting_payment'
| 'confirmed'
| 'active'
| 'completed'
| 'cancelled';
const KEY_ISSUABLE_STATES: ReadonlySet<CheckInState> = new Set(['confirmed', 'active']);
export interface KeyIssuanceContext {
checkIn: { id: string; state: CheckInState; startAt: Date; endAt: Date };
now: Date;
}
export function assertKeyIssuable(ctx: KeyIssuanceContext): void {
const { checkIn, now } = ctx;
if (!KEY_ISSUABLE_STATES.has(checkIn.state)) {
throw new AccessDeniedError('KEY_ISSUE_STATE_INVALID', {
checkInId: checkIn.id,
state: checkIn.state,
allowed: [...KEY_ISSUABLE_STATES],
});
}
if (now > checkIn.endAt) {
throw new AccessDeniedError('KEY_ISSUE_WINDOW_EXPIRED', { checkInId: checkIn.id });
}
}これを信頼できるものにしている性質は3つあります。
- 拒否リストではなく許可リスト。 後から追加された新しい状態は、デフォルトで発行不可になります。拒否リスト(
if (state === 'cancelled') throw)では、列挙し忘れたすべての状態に対して暗黙のうちにアクセスを許してしまいます。 - 構造化されたエラーコード。
KEY_ISSUE_STATE_INVALIDは機械可読で、ログに残せ、翻訳も可能です。自由記述のメッセージよりはるかに優れています。 - 有効期間もガードの一部。 資格情報は、それを正当化した状態の時間的境界を継承します。
鍵発行の呼び出し側は、薄いラッパーになります。
export async function issueEntryKey(checkInId: string, actor: Actor) {
const checkIn = await repo.getCheckInForUpdate(checkInId);
assertKeyIssuable({ checkIn, now: new Date() });
const credential = await lockApi.createPinCode({
lockId: checkIn.lockId,
validFrom: checkIn.startAt,
validUntil: checkIn.endAt,
});
await audit.record({
event: 'entry_key.issued',
actorId: actor.id,
subjectId: checkInId,
metadata: { credentialId: credential.id, state: checkIn.state },
});
return credential;
}getCheckInForUpdate に注目してください。状態の読み取りは、発行と同一のトランザクション内で行ロックのもとに行われます。そのため、チェックと実行の間に並行するキャンセルが割り込むこと(TOCTOU)はありません。
2. データベースビューは呼び出し元として実行されなければならない
一連のpublicビューを SECURITY DEFINER から SECURITY INVOKER へ変更しました。これはPostgreSQL/RLSアーキテクチャで利用できるセキュリティ修正のなかでも最もレバレッジの高いもののひとつであり、日常的に誤解されている点でもあります。
SECURITY DEFINER では、ビューはその所有者の権限で実行されます。所有者がスーパーユーザー、あるいはRow Level Securityをバイパスするロールである場合、ビューをSELECTできる人に対して基盤テーブル上のすべてのRLSポリシーが暗黙のうちにバイパスされます。テナントスコープのテーブルが、全データのエクスポート口になってしまうのです。
-- BEFORE: view owner's privileges apply; caller RLS is bypassed
CREATE VIEW public.reservation_summary
WITH (security_invoker = false) AS
SELECT r.id, r.facility_id, r.guest_name, r.total_fee
FROM public.reservations r;
-- AFTER: the view executes with the caller's privileges, so RLS is enforced
CREATE OR REPLACE VIEW public.reservation_summary
WITH (security_invoker = true) AS
SELECT r.id, r.facility_id, r.guest_name, r.total_fee
FROM public.reservations r;これにより、基盤のポリシーが実際に機能するようになります。
ALTER TABLE public.reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY reservations_tenant_isolation ON public.reservations
FOR SELECT USING (
facility_id IN (
SELECT m.facility_id FROM public.memberships m
WHERE m.user_id = auth.uid() AND m.revoked_at IS NULL
)
);運用上の教訓: これは一度きりではなく継続的に監査してください。単純なガードクエリを、マイグレーション済みのデータベースに対してCIで実行できます。
SELECT c.relname AS view_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'v'
AND n.nspname = 'public'
AND COALESCE((SELECT option_value FROM pg_options_to_table(c.reloptions)
WHERE option_name = 'security_invoker'), 'false') <> 'true';結果セットが空でなければビルドを失敗させます。レビューでしか検出できないセキュリティ上のリグレッションは、いずれ検出されなくなります。
3. ロールのライフサイクル:付与、受諾、剥奪
関連する2つの修正を出しました。招待の受諾は既存のオーナーロールを降格させずに保持しなければならないこと、そしてメンバーの削除はメンバーシップ行を消すだけでなくロールを剥奪しなければならないことです。3つ目の修正では、受諾時に招待のステータスを同期させ、同じ招待が二度使われないようにしました。
これらはすべて同じアンチパターンの症状です。すなわち、認可を「たまたま一緒に更新される疎結合な行の集合」として扱ってしまうことです。
// ANTI-PATTERN: partial writes leave the system in a half-authorized state
async function acceptInvite(inviteId: string, userId: string) {
const invite = await db.invites.findById(inviteId);
await db.memberships.upsert({ userId, orgId: invite.orgId, role: invite.role });
// invite left 'pending' -> replayable; existing owner silently downgraded
}正しい実装は、アトミックで冪等であり、権限に関して単調です。
const ROLE_RANK = { viewer: 0, member: 1, manager: 2, owner: 3 } as const;
export type Role = keyof typeof ROLE_RANK;
export function effectiveRole(existing: Role | null, incoming: Role): Role {
if (!existing) return incoming;
return ROLE_RANK[existing] >= ROLE_RANK[incoming] ? existing : incoming;
}
export async function acceptInvite(inviteId: string, userId: string) {
return db.transaction(async (tx) => {
const invite = await tx.invites.lockById(inviteId);
if (invite.status !== 'pending') {
throw new ConflictError('INVITE_ALREADY_RESOLVED', { status: invite.status });
}
if (invite.expiresAt < new Date()) {
throw new ConflictError('INVITE_EXPIRED');
}
const current = await tx.memberships.find({ userId, orgId: invite.orgId });
const role = effectiveRole(current?.role ?? null, invite.role);
await tx.memberships.upsert({ userId, orgId: invite.orgId, role, revokedAt: null });
await tx.invites.update(inviteId, { status: 'accepted', acceptedBy: userId });
await tx.audit.record({ event: 'membership.granted', subjectId: userId, metadata: { role } });
});
}削除はその鏡像です。重要なのは、派生した権限も併せて剥奪する点です。
export async function removeMember(orgId: string, userId: string, actor: Actor) {
return db.transaction(async (tx) => {
await tx.memberships.revoke({ orgId, userId, revokedAt: new Date() });
await tx.roleAssignments.revokeAllFor({ orgId, userId });
await tx.invites.cancelPendingFor({ orgId, email: null, userId });
await tx.sessions.invalidateForOrg({ orgId, userId });
await tx.audit.record({ event: 'membership.revoked', actorId: actor.id, subjectId: userId });
});
}経験則: ユーザーの削除に複数テーブルの行削除が必要なら、その削除は監査レコードとともに単一トランザクションにまとめるべきです。そうでなければ、途中でクラッシュしたときに残留権限を持つゴーストが残ります。
4. Plan → Apply → Verify:ドリフトを信用せず検出する
複数フェーズにわたる取り組みで、決定的なプロビジョニングプランナー、コンフリクト検出、applyオーケストレーター、そして最後にラウンドトリップ検証を導入しました。特筆すべきは、後続のセルフレビューのコミットでverifier自体を修正したことです。というのも、それは実際のドリフトを見逃していたからです。これは見つける価値が最も高い種類のバグです。常にパスするverifierは、verifierが無いことよりも悪い(誤った安心感を作り出す)からです。
一般的なパターンはTerraform風のループであり、外部状態を変更するあらゆるシステム(ロックコントローラー、決済プロバイダー、通知チャネルなど)に適用できます。
export interface ResourcePlan<T> {
kind: string;
id: string;
desired: T;
action: 'create' | 'update' | 'noop';
}
export interface DriftReport {
id: string;
field: string;
expected: unknown;
actual: unknown;
}
export async function applyAndVerify<T extends object>(
plans: ReadonlyArray<ResourcePlan<T>>,
driver: { apply(p: ResourcePlan<T>): Promise<void>; read(id: string): Promise<T | null> },
): Promise<DriftReport[]> {
for (const plan of plans) {
if (plan.action !== 'noop') await driver.apply(plan);
}
const drift: DriftReport[] = [];
for (const plan of plans) {
const actual = await driver.read(plan.id);
if (actual === null) {
drift.push({ id: plan.id, field: '*', expected: plan.desired, actual: null });
continue;
}
drift.push(...diffStrict(plan.id, plan.desired, actual));
}
return drift;
}難しいのは diffStrict の部分です。verifierがドリフトを見逃す理由は主に3つあります。
- 書き込み側が変更しようと意図したフィールドしか比較しないため、想定外のサーバー側の変更が見えない。
- 緩い等価比較を使うため、
0とnull、"100"と100、undefinedとキー欠落がすべて等しいと判定される。 - 計画上のアクションが
noopのリソースをスキップするが、まさにそこにドリフトが蓄積する。
function diffStrict<T extends object>(id: string, expected: T, actual: T): DriftReport[] {
const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
const out: DriftReport[] = [];
for (const key of keys) {
const e = (expected as Record<string, unknown>)[key];
const a = (actual as Record<string, unknown>)[key];
if (!Object.is(normalize(e), normalize(a))) {
out.push({ id, field: key, expected: e, actual: a });
}
}
return out;
}
function normalize(v: unknown): unknown {
if (v === undefined) return null;
if (typeof v === 'object' && v !== null) return JSON.stringify(v);
return v;
}同じシリーズの関連修正では、暗黙のデモ価格を削除し、承認ゲートのバイパスを塞ぎました。原則はこうです。実データが欠けているときに黙ってフォールバック値を代入するシステムは、実行時には正常に動作しているシステムと区別がつきません。大きな音を立てて失敗させましょう。
function resolvePrice(source: PriceSource): number {
if (source.kind === 'catalog') return source.amount;
throw new ConfigurationError('PRICE_SOURCE_UNRESOLVED', { kind: source.kind });
}5. 境界でのバリデーション
小さな修正の一群——過去の開始時刻を持つ予約のブロック、プランフォームでの負値の拒否、招待を送る前のメールアドレス形式の検証——は、いずれも同じ規律を表しています。すなわち、信頼境界でバリデーションを行い、UIが既に検証していてもサーバー側で必ず検証するということです。
クライアント側のバリデーションはUXのための支援機能です。サーバー側のバリデーションこそがセキュリティコントロールです。スキーマファーストのアプローチなら、ひとつの定義から両方が得られます。
import { z } from 'zod';
export const CreateReservationInput = z.object({
facilityId: z.string().uuid(),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
guestEmail: z.string().email().optional(),
guestPhone: z.string().min(1).optional(),
}).superRefine((v, ctx) => {
if (v.endAt <= v.startAt) {
ctx.addIssue({ code: 'custom', path: ['endAt'], message: 'END_BEFORE_START' });
}
if (v.startAt.getTime() < Date.now() - 60_000) {
ctx.addIssue({ code: 'custom', path: ['startAt'], message: 'START_IN_PAST' });
}
});
export type CreateReservationInput = z.infer<typeof CreateReservationInput>;これを単なるランタイムチェックではなく型安全性の改善たらしめているのが z.infer の行です。コンパイル時の型がランタイムのバリデーターから導出されるため、両者が乖離することはありません。
同じ規律の金銭版もあります。価格計算の修正では、中間の各要素をそれぞれ丸めるのではなく合計を一度だけ丸めることで、端数のある時間に対する丸め誤差を解消しました。また別の修正では、最低料金の計算における税の二重控除を止めました。金額計算は整数の補助単位(minor units)で行い、人間または決済プロバイダーがその数値を目にする境界でちょうど一度だけ丸めるべきです。
// Round once at the end, in minor units.
function computeTotalMinorUnits(hours: number, hourlyRateMinor: number, taxRate: number): number {
const subtotal = hours * hourlyRateMinor;
const withTax = subtotal * (1 + taxRate);
return Math.round(withTax);
}6. エラー状態の衛生管理とシークレットの扱い
もう2つ、単発のバグではなく欠陥のクラスを代表するものとして触れておく価値があります。
画面をまたいで漏れ出す古いエラー状態。 決済エラーが、ユーザーが決済ステップから離れた後も残り続け、確認ステップで再表示されていました。状態機械の用語で言えば、そのエラーはセッションではなく「状態」に属するものです。したがって、その状態からの退出時にクリアされなければなりません。
const checkoutMachine = {
states: {
payment: {
exit: 'clearPaymentError',
on: { BACK: 'confirm', SUCCESS: 'complete', FAILURE: { actions: 'setPaymentError' } },
},
confirm: { on: { PAY: 'payment' } },
},
};UIフローを、entry/exitアクションを持つ明示的な状態としてモデル化すれば、通常は手動QAでしか見つからない「ゴースト状態」系のバグをまとめて排除できます。
使われていない暗号処理は負債です。 いくつかのコミットで、連携用の資格情報に対する未使用の暗号化パスを削除しました。死んだセキュリティコードは二方向に危険です。レビュアーはそれが何かを守っていると思い込みますが(実際は守っていません)、さらに古い鍵素材のまま誤って再有効化されることもあります。シークレットは、文書化・テストされた経路で扱われるか、さもなくばその経路を削除してストレージモデルを明示的に述べるかのどちらかであるべきです。そして実際の保護は、それが強制される場所(カラムレベルのRLS、KMS、専用のシークレットストア)へ移します。曖昧さこそが脆弱性です。
最後に、CIワークフローをEOLとなったNode 18からNode 20へ引き上げました。セキュリティパッチが提供されなくなったランタイム上でビルドや公開を行うのは、単なる雑務ではなくサプライチェーンリスクです。
7. リスクに見合うテスト戦略
招待の受諾については、純粋なヘルパーのユニットテストだけでなく、RPCおよびハンドラーレベルでの振る舞いテストを追加しました。この階層化は認可ロジックにとって重要です。
| レイヤー | 何を証明するか | 例 |
|---|---|---|
| Unit | 純粋な判定ロジックが正しいこと | effectiveRole('owner', 'member') === 'owner' |
| Handler/RPC | トランザクション境界、ロック、エラーマッピング | 使用済みの招待を受諾すると INVITE_ALREADY_RESOLVED が返る |
| Integration (DB) | RLSポリシーが実際にテナント間の読み取りを拒否すること | テナントBとしてSELECTするとテナントAの行は0件 |
| E2E | ユーザーから見えるフローでガードを回避できないこと | キャンセル済み予約では入室鍵を取得できない |
describe('acceptInvite', () => {
it('does not downgrade an existing owner', async () => {
await seedMembership({ userId: 'u1', orgId: 'o1', role: 'owner' });
const invite = await seedInvite({ orgId: 'o1', role: 'member' });
await acceptInvite(invite.id, 'u1');
expect(await getRole('u1', 'o1')).toBe('owner');
});
it('rejects a second redemption of the same invite', async () => {
const invite = await seedInvite({ orgId: 'o1', role: 'member' });
await acceptInvite(invite.id, 'u1');
await expect(acceptInvite(invite.id, 'u2')).rejects.toMatchObject({
code: 'INVITE_ALREADY_RESOLVED',
});
});
});RLSのテストは、チームが最も省略しがちでありながら、SECURITY DEFINER ビューの問題を捉えられたはずのテストです。
BEGIN;
SET LOCAL role = 'authenticated';
SET LOCAL request.jwt.claims = '{"sub":"tenant-b-user"}';
SELECT count(*) = 0 AS isolated FROM public.reservation_summary WHERE facility_id = 'tenant-a-facility';
ROLLBACK;まとめ
今週の変更を貫くテーマは、信頼は導出され、検証され、取り消し可能でなければならないということです。
- 導出される — 鍵が存在するのは、ロックされたトランザクション内で、チェックインが発行可能な状態にあるからに他なりません。
- 検証される — データベースは
SECURITY INVOKERビューとRLSによってテナント分離を強制し、プロビジョニングパイプラインは書き込んだ内容を読み直して厳密比較でドリフトを検出します。 - 取り消し可能である — メンバーの削除は、メンバーシップ、ロール割り当て、保留中の招待、セッションをアトミックに剥奪し、すべての遷移について監査レコードを残します。
そしてverifierの修正から得られたメタな教訓がひとつ。自分のセーフティネット自体をセルフレビューしましょう。 一度も発火しないガード、一度もドリフトを報告しないverifier、不正な入力を黙って型変換するバリデーターは、ダッシュボード上では健全なシステムとまったく同じに見えます。ガードが失敗しうることを証明するテストを書いてください。