フェイルクローズドな認可:招待フロー堅牢化の教訓
SDK が物理的なドアを制御しているとき、認可のバグはデータ漏洩では済みません。見知らぬ他人が誰かのリビングルームに立っている、という事態を意味します。ここ数週間、私たちのチームはアクセス制御層にまたがる一連の変更をリリースしました。組織をまたぐ権限昇格の封じ込め、フェイルクローズドをデフォルトとする role_key の許可リスト導入、過剰に緩いストレージ読み取りポリシーの削除、そしてクレーム伝播を「暗黙のベストエフォート」から観測可能な処理へと変えることです。
本記事では、それらの変更を、ミスの影響範囲が物理世界に及ぶあらゆるマルチテナントシステムに応用できるパターンとして整理します。
1. テナント越え昇格のパターン
最も危険な認可バグは、「チェックがまったく存在しない」ケースではめったにありません。間違った関係性を検証しているチェックこそが危険なのです。
招待エンドポイントを考えてみましょう。呼び出し元は facilityId と roleId を渡します。素朴な実装は、呼び出し元がどこかのオーナーであることだけを検証して、メンバーシップ行を書き込んでしまいます。
// ❌ Vulnerable: verifies the caller is an owner, but not of THIS facility
async function inviteMember(caller: User, input: InviteInput) {
const isOwner = await db.memberships.exists({
userId: caller.id,
role: 'owner',
});
if (!isOwner) throw new ForbiddenError();
return db.invitations.insert({
facilityId: input.facilityId,
roleId: input.roleId,
});
}これでは、組織 A のオーナーが、組織 B に属する施設へ——しかも任意のロールで——自分自身を招待できてしまいます。チェックは通過しました。しかし認可は失敗しています。
修正の要点は、チェックの主体と客体を明示し、切り離せない形にすることです。
// ✅ The check binds caller identity to the specific resource
export async function checkFacilityOwnerAccess(
callerId: string,
facilityId: string,
): Promise<AccessResult> {
if (!isUuid(facilityId)) {
return { allowed: false, reason: 'INVALID_FACILITY_ID' };
}
const row = await db.memberships.findOne({
userId: callerId,
facilityId,
status: 'active',
});
if (!row) return { allowed: false, reason: 'NOT_A_MEMBER' };
if (!OWNER_TIER_ROLES.has(row.roleKey)) {
return { allowed: false, reason: 'INSUFFICIENT_ROLE' };
}
return { allowed: true, roleKey: row.roleKey };
}見た目以上に重要な点が 2 つあります。
- UUID の検証をクエリの前に行っている。 不正な形式の識別子は、予期しない挙動を引き起こしうるクエリへ流し込むのではなく、不正な入力として拒否すべきです。
- 関数が構造化された理由を返す。 これにより、呼び出し元に理由を漏らすことなく監査ログへ供給できます(詳細は後述)。
一度定義したら、実際に呼び出す
私たちは、checkFacilityOwnerAccess が定義されているにもかかわらず、すぐ近くの一覧エンドポイントが独自の弱いゲートをインライン実装している箇所を見つけました。ヘルパーを定義しただけでは強制力はありません。lint ルールやアーキテクチャテストを用意する価値があります。
// tests/arch/authz.test.ts
import { readdirSync, readFileSync } from 'node:fs';
const MUTATING_HANDLERS = /export async function (create|update|delete|invite)/;
it('every mutating handler references an authz helper', () => {
for (const file of listHandlerFiles()) {
const src = readFileSync(file, 'utf8');
if (!MUTATING_HANDLERS.test(src)) continue;
expect(src).toMatch(/check(FacilityOwnerAccess|OrgAdminAccess)/);
}
});2. ロール割り当てには拒否リストではなく許可リストを
ロール昇格は、API が深く考えずに転送しているフィールドから侵入してくるのが常です。クライアントが roleKey: "platform_admin" を送信でき、サーバーが届いた値をそのまま保存するなら、root 権限を配っているのと同じです。
拒否リストは腐ります。新しい特権ロールが追加されても、誰もブロックリストを更新しないからです。許可リストは構造的にフェイルクローズドになります。
const ASSIGNABLE_BY_OWNER = new Set([
'facility_manager',
'front_desk',
'housekeeping',
'member',
] as const);
type AssignableRole = typeof ASSIGNABLE_BY_OWNER extends Set<infer T> ? T : never;
export function assertAssignableRole(
roleKey: string,
callerRole: string,
): asserts roleKey is AssignableRole {
// Unknown role keys are rejected, not ignored.
if (!ASSIGNABLE_BY_OWNER.has(roleKey as AssignableRole)) {
throw new AuthzError('ROLE_NOT_ASSIGNABLE', { roleKey, callerRole });
}
}ここでは TypeScript の asserts シグネチャが実質的な仕事をしています。呼び出し後、コンパイラは roleKey を安全な値のユニオンへ絞り込むため、下流のコードが誤って任意の文字列を永続化層へ渡すことはできなくなります。
ロールには UUID ではなく安定したキーを与える
私たちは既存の roles.id と並べて role_key カラムを導入しました。UUID で分岐する認可ロジックは可読性もレビュー可能性もありません。レビュアーには a3f1... が front_desk なのか platform_admin なのか判断できないからです。安定した文字列キーがあれば、ポリシーコードは監査可能になります。
ALTER TABLE roles
ADD COLUMN role_key text;
UPDATE roles SET role_key = slugify(name) WHERE role_key IS NULL;
ALTER TABLE roles
ALTER COLUMN role_key SET NOT NULL,
ADD CONSTRAINT roles_role_key_unique UNIQUE (role_key),
ADD CONSTRAINT roles_role_key_format CHECK (role_key ~ '^[a-z][a-z0-9_]*$');カラム追加 → バックフィル → NOT NULL の強制 → ポリシー移行、と段階的に展開することで、各ステップで巻き戻し可能な状態を保てます。
3. 行レベルセキュリティ:保留中招待の漏洩
招待テーブルは、見た目以上に機微な情報を含みます。メールアドレス、対象施設、付与予定のロール——つまり「誰がこれから何にアクセスできるようになるか」の地図です。
よくある RLS ポリシーはこんな形をしています。
-- ❌ Any authenticated user can enumerate pending invitations
CREATE POLICY select_invitations ON facility_invitations
FOR SELECT TO authenticated
USING (true);意図は「招待された人は、メンバーシップ行ができる前に自分の招待を読めなければならない」でした。しかし実装はグローバルな読み取りを許可してしまっています。
修正後のポリシーは、読み取りを正当な 2 者——招待者本人(検証済みの identity で照合)と施設オーナー——にスコープします。
DROP POLICY IF EXISTS select_invitations ON facility_invitations;
CREATE POLICY select_own_invitation ON facility_invitations
FOR SELECT TO authenticated
USING (
lower(invited_email) = lower(auth.jwt() ->> 'email')
OR EXISTS (
SELECT 1 FROM memberships m
WHERE m.facility_id = facility_invitations.facility_id
AND m.user_id = auth.uid()
AND m.role_key IN ('owner', 'org_admin')
)
);auth.users という落とし穴
関連する罠として、認証スキーマのユーザーテーブルと結合する RLS 述語を書いてしまうケースがあります。そうしたテーブルには独自の特権アクセスルールがあることが多く、テナントポリシーから参照すると、制限されたロールではエラーになるか、あるいは黙って可視範囲を広げてしまいます。すでに JWT に含まれるクレーム(auth.uid()、auth.jwt() ->> 'email')か、自分が所有するテーブルを使いましょう。ポリシーは自分がコントロールできるデータだけに依存すべきです。
4. ストレージバケットにも同じ規律を
私たちは、欠落していた id-documents バケットを作成し、同時にそのバケット上の安全でない読み取りポリシーを削除する修正をリリースしました。オブジェクトストレージは往々にして最も弱いリンクになります。開発中にその場しのぎで作られたバケットは、コンソールが提示したデフォルト設定をそのまま引き継いでしまうからです。
身分証明書を保持するバケットに関する 2 つのルールです。
- バケットはコードとして存在させる。 バケットが存在しないと実行時エラーになり、開発者はそれを「修正」するために、緩い設定で手動作成しがちです。マイグレーションで宣言しましょう。
- 広範な
SELECTを作らない。 読み取りは、自身で認可チェックを行うサーバー関数が発行する短命の署名付き URL 経由に限定します。
INSERT INTO storage.buckets (id, name, public)
VALUES ('id-documents', 'id-documents', false)
ON CONFLICT (id) DO UPDATE SET public = false;
DROP POLICY IF EXISTS "id_documents_public_read" ON storage.objects;
-- No SELECT policy for `authenticated`. Reads require a signed URL
-- minted by a server function after an explicit authorization check.
CREATE POLICY "id_documents_insert_own" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'id-documents'
AND (storage.foldername(name))[1] = auth.uid()::text
);export async function getIdDocumentUrl(caller: User, reservationId: string) {
const access = await checkReservationStaffAccess(caller.id, reservationId);
if (!access.allowed) {
await audit.record('id_document.access_denied', {
callerId: caller.id,
reservationId,
reason: access.reason,
});
throw new ForbiddenError();
}
await audit.record('id_document.accessed', { callerId: caller.id, reservationId });
return storage.createSignedUrl(access.objectPath, { expiresIn: 60 });
}両方の分岐で監査記録を残している点に注目してください。拒否された試行こそ、インシデントレビューで最も欲しいシグナルです。
5. クレーム伝播は観測可能でなければならない
ロールが JWT クレームに存在する場合、ロールの変更にはもう 1 ステップ、トークンのリフレッシュが必要です。このステップが fire-and-forget だと、データベースの言うことと セッションの言うことが食い違う、静かで再現しにくい種類のバグが生まれます。錠前を制御するシステムでは、これは「権限を剥奪されたスタッフがトークンの有効期限まではアクセスを保持し続ける」ことを意味しかねません。
私たちはクレーム同期を、追跡されない副作用から、明示的でリトライされ表面化される操作へと移行しました。
export async function syncClaims(userId: string): Promise<SyncResult> {
const MAX_ATTEMPTS = 3;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { error } = await functions.invoke('sync-user-claims', {
body: { userId },
});
if (error) throw error;
await audit.record('claims.synced', { userId, attempt });
return { ok: true };
} catch (err) {
lastError = err;
if (attempt < MAX_ATTEMPTS) {
await sleep(200 * 2 ** (attempt - 1));
}
}
}
await audit.record('claims.sync_failed', { userId, error: String(lastError) });
return { ok: false, error: lastError };
}そして呼び出し側では、失敗が人間に見える形になっています。
const result = await syncClaims(member.userId);
if (!result.ok) {
toast.error(t('authz.claimsSyncFailed'));
// The DB write already succeeded; the operator must know the session
// may be stale and should force a re-login.
}指数バックオフが一時的なネットワーク障害を吸収し、最終的なトーストが残りを処理します。決してやってはいけないのは、エラーを握りつぶすことです。成功したように見えて実際には伝播していない権限変更は、目に見えて失敗した変更よりもたちが悪いのです。
6. 信頼境界における安定したエラーコード
この期間のいくつかのコミットは、同じ考え方に収束しました。サーバー関数は機械可読なエラーコードを返し、クライアントがそれをローカライズされたメッセージへマッピングする、という方針です。
export const CHECKIN_ERRORS = {
RESERVATION_NOT_FOUND: 'RESERVATION_NOT_FOUND',
RESERVATION_ALREADY_CHECKED_IN: 'RESERVATION_ALREADY_CHECKED_IN',
PLAN_NOT_AVAILABLE_ON_DATE: 'PLAN_NOT_AVAILABLE_ON_DATE',
PAYMENT_REQUIRED: 'PAYMENT_REQUIRED',
ROOM_HAS_ACTIVE_RESERVATIONS: 'ROOM_HAS_ACTIVE_RESERVATIONS',
} as const;
export type CheckinErrorCode =
(typeof CHECKIN_ERRORS)[keyof typeof CHECKIN_ERRORS];export function useResolvableError() {
const { t } = useTranslation();
return useCallback(
(err: unknown): string => {
const code = extractErrorCode(err);
if (code && i18n.exists(`errors.${code}`)) {
return t(`errors.${code}`);
}
// Unknown codes never surface raw server text to the user.
reportUnmappedError(code ?? 'UNKNOWN', err);
return t('errors.GENERIC');
},
[t],
);
}これにはセキュリティに隣接する 3 つの利点があります。
- 内部情報の漏洩がない。 スタックトレース、SQL 断片、行識別子が UI に到達することはありません。
- クライアント挙動の決定性。 リトライロジックはコードをキーにします。メッセージが書き換えられたり翻訳されたりすると壊れる文字列マッチには依存しません。
- テレメトリ。
reportUnmappedErrorは「ユーザーが汎用エラーを見た」という事象を、アクション可能なシグナルへ変えます。
併せて行った修正:画面遷移時に古いエラーをクリアする。 前のステップのエラーメッセージがユーザーの移動後も残り続けると、信頼を損ないますし、さらに悪いことに、本当に新しく発生した障害を覆い隠してしまいます。
useEffect(() => {
setConfirmError(null);
}, [currentStep]);7. 冪等性と二重送信ガード
2 回クリックできてしまう OTP 検証ボタンは、最悪の場合、ワンタイムコードを消費したうえで 2 回目のリクエストで失敗を報告し、ユーザーをドアの外に締め出すことになります。
function useVerifyOtp() {
const inFlight = useRef(false);
const [pending, setPending] = useState(false);
const verify = useCallback(async (code: string) => {
if (inFlight.current) return;
inFlight.current = true;
setPending(true);
try {
return await api.verifyOtp({ code });
} finally {
inFlight.current = false;
setPending(false);
}
}, []);
return { verify, pending };
}useRef によるガードは意図的です。setPending は非同期であり、同じ tick 内で発火した 2 回目のクリックをブロックできません。ref は同期的に更新されます。
クライアント側のガードは UX の改善であって、セキュリティ制御ではありません。サーバーは依然として冪等でなければなりません。リクエスト識別子で操作をキー付けし、繰り返しの呼び出しには元の結果を返しましょう。
8. 決定論的ビルドはサプライチェーン対策である
ごく小さな CI の変更が、不釣り合いなほど大きな意味を持ちます。すべてのワークフローで frozen lockfile を使うことです。
- name: Install dependencies
run: pnpm install --frozen-lockfileこれがないと、CI はレビューされたものとは異なる依存ツリーを解決しうります。物理的なクレデンシャルを発行するシステムにおいて、「テストした成果物が出荷する成果物である」ことは利便性ではなくセキュリティ特性です。また、lockfile とマニフェストが食い違った瞬間に、静かなドリフトを大きな CI 失敗へと変換してくれます。
関連して、私たちは main ブランチで red のまま放置されていた古いテストの修正に 1 スプリントを費やしました。恒久的に失敗するテストスイートは、スイートが存在しないのと同じです。エンジニアは読まなくなり、次の本物のリグレッションがすり抜けます。CI をグリーンに保つことは、本記事の他のすべての保証の前提条件です。
まとめ
これらの変更に共通するテーマは、構造としてフェイルクローズドであることです。
| 関心事 | アンチパターン | パターン |
|---|---|---|
| リソース認可 | 呼び出し元のロールをグローバルに確認 | 呼び出し元の identity を特定リソースに束縛 |
| ロール割り当て | 危険なロールの拒否リスト | 割り当て可能なロールの許可リスト |
| 行レベルセキュリティ | 便宜上の USING (true) |
招待者とオーナーへ明示的にスコープ |
| オブジェクトストレージ | バケットへの広範な読み取りポリシー | 読み取りポリシーなし。短命の署名付き URL |
| クレーム伝播 | Fire-and-forget | バックオフ付きリトライ、監査、失敗の表面化 |
| エラー | 自由記述のサーバーメッセージ | 安定コードをクライアント側でマッピング |
| ビルド | 浮動的な依存解決 | CI での frozen lockfile |
どれも特殊なテクニックではありません。これらを機能させるのは、一貫して適用すること——そして誰かが忘れたときに失敗するテストを追加することです。ドアを開けるシステムにおいて、「これは許可されるべきか?」に対するデフォルトの答えは、明示的に許可するものが現れるまでは No でなければなりません。