クレームベースRLS、フェイルクローズドなガード、そしてテナント分離
SDK が物理的なドアロックを制御する場合、認可パス上のあらゆる曖昧さは「解錠されたままのドア」になりかねません。直近のスプリントで UnlockOS プラットフォームはセキュリティ重視の総点検を行いました。テナントテーブルで row-level security を再有効化し、権限チェックをレガシーなルックアップテーブルから署名済み JWT クレームへ移行し、SECURITY DEFINER ビューを削除し、決済関数にモジュールロード時の環境変数ガードを追加し、token manager にあった暗黙の「自動解決」コードパスを削除して明示的なパラメータに置き換えました。
これらの変更はどれも新機能を出荷したわけではありません。すべては「システムを誤用しにくくする」ための変更です。本記事では、その背後にあるパターンを、マルチテナントかつセキュリティが重要なあらゆるバックエンドに応用できる形で解説します。
1. テーブルではなくトークンを信頼する
典型的な権限チェックの実装は、テーブルを select して「このユーザーは管理者か?」をデータベースに問い合わせます。
-- Anti-pattern: privilege check depends on a readable table
create or replace function public.is_platform_admin()
returns boolean
language sql
stable
as $$
select exists (
select 1 from public.platform_admins
where user_id = auth.uid()
);
$$;これには3つの問題があります。第一に、すべての RLS ポリシー評価に追加のテーブルスキャンが結び付いてしまいます。第二に、そのテーブル自体にも RLS ポリシーが必要であり、そのポリシーが is_platform_admin() を参照すると再帰が発生します。第三に、「誰が管理者か」の信頼できる情報源が認証プロバイダとアプリケーションデータベースの2箇所に存在することになり、両者は乖離していきます。
解決策は、署名済みトークンを唯一の信頼できる情報源にすることです。ID プロバイダがログイン時にクレームを刻印し、データベースはテーブルに触れることなくそれを読み取ります。
create or replace function public.is_platform_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
select coalesce(
(
nullif(current_setting('request.jwt.claims', true), '')::jsonb
-> 'app_metadata' ->> 'is_platform_admin'
)::boolean,
false
);
$$;見た目以上に重要な2つのポイントがあります。
current_setting('request.jwt.claims', true)はmissing_ok = trueを使っているため、未認証やバックグラウンドのコンテキストでは例外ではなくNULLを返します。coalesce(..., false)と組み合わせることで、この関数は フェイルクローズド になります。set search_path = ''はSECURITY DEFINER関数に対する search path ハイジャック(Postgres でよく知られた権限昇格の経路)を防ぎます。
app_metadata は service role のみが書き込み可能で、JWT に署名されて含まれるため、クライアントがクレームを偽造することはできません。また、この関数がテーブルを読まなくなったことで、再帰を気にせずポリシー内で自由に使えるようになります。
2. RLS を再び有効にする — ただし推論可能なポリシーとともに
「アプリケーション層で保護されている」テナントテーブルは、保護されていません。anon key が漏洩したり、.eq('organization_id', ...) フィルタを1つ書き忘れたりした時点で、分離は失われます。organizations のようなコアテーブルで RLS を再有効化しても安全なのは、ポリシーが軽量かつ非再帰的な場合だけです。これはまさにクレームベースのヘルパーが可能にするものです。
alter table public.organizations enable row level security;
create policy organizations_select_self
on public.organizations
for select
to authenticated
using (
public.is_platform_admin()
or id = public.current_organization_id()
);
create policy organizations_write_admin_only
on public.organizations
for all
to authenticated
using (public.is_platform_admin())
with check (public.is_platform_admin());変更を伴うポリシーには、必ず using と with check の両方を書いてください。using は「どの行を参照・対象にできるか」を制御し、with check は「書き込み後に行がどのような状態であってよいか」を制御します。with check を省略すると、権限を持つユーザーが行を別のテナントへ移動できてしまいます。
ビューはデフォルトで誤ったアイデンティティを継承する
Postgres のビューは歴史的に所有者の権限で実行され、その結果、基となるテーブルの RLS を黙って迂回します。レポート用のビューをクライアントに公開するなら、invoker セマンティクスに切り替えましょう。
alter view public.v_user_transactions set (security_invoker = on);そのうえで、本来強制したかった条件を追加します。例えば、取引履歴を閲覧できる前提としてユーザーの連絡先アドレスが検証済みであること、といった条件です。
create policy user_transactions_select_owner
on public.user_transactions
for select
to authenticated
using (
user_id = auth.uid()
and coalesce(
(auth.jwt() -> 'user_metadata' ->> 'email_verified')::boolean,
false
)
);3. リクエスト時ではなくモジュールロード時にフェイルクローズドする
決済やロック資格情報を扱う Edge/serverless 関数は、通常は環境からシークレットを読み取ります。危険なのは、それを遅延的に読む実装です。
// Anti-pattern: a missing secret becomes an empty string at request time
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '');こうすると、設定不備のあるデプロイでも起動には成功し、チェックアウトの途中で失敗します。しかもロックの権限付与がすでに発行された後かもしれません。代わりに、モジュールロード時に環境全体を一度だけ検証し、起動を拒否しましょう。
const REQUIRED_ENV = [
'STRIPE_SECRET_KEY',
'STRIPE_WEBHOOK_SECRET',
'SUPABASE_URL',
'SUPABASE_SERVICE_ROLE_KEY',
] as const;
type RequiredEnvKey = (typeof REQUIRED_ENV)[number];
export function requireEnv(
keys: readonly RequiredEnvKey[],
): Readonly<Record<RequiredEnvKey, string>> {
const missing: string[] = [];
const resolved = {} as Record<RequiredEnvKey, string>;
for (const key of keys) {
const value = Deno.env.get(key);
if (!value || value.trim() === '') {
missing.push(key);
continue;
}
resolved[key] = value;
}
if (missing.length > 0) {
throw new Error(`[boot] missing required environment variables: ${missing.join(', ')}`);
}
return Object.freeze(resolved);
}as const のタプルと、そこから導出した RequiredEnvKey ユニオンにより、返されるレコードは完全に型付けされます。env.STRIPE_SECRET_KEY は string であり、決して string | undefined にはならず、キー名のタイプミスはコンパイルエラーになります。
実務上の注意点が1つあります。import 時に例外を投げると、単にモジュールを import するだけのユニットテストや静的解析が壊れてしまいます。ガードを弱めるのではなく、ゲートを設けましょう。
const SKIP_BOOT_GUARD = Deno.env.get('SKIP_ENV_GUARD') === 'true';
export const env = SKIP_BOOT_GUARD
? ({} as Readonly<Record<RequiredEnvKey, string>>)
: requireEnv(REQUIRED_ENV);この抜け道はオプトインであり、明示的に名前が付けられており、本番のデプロイ設定では決して設定されません。
4. テスト用と本番用の資格情報を推測しない
関連するバグの類型として、設定画面に「テストモード」トグルがあるのに、そのフラグがコンポーネントの state にしか保持されず永続化されていない、というものがあります。次のリクエストでサーバーは undefined を受け取り、本番キーにフォールバックし、リハーサル中に実際のカードへ課金してしまいます。
防御的な形は、「不明なモード」をデフォルトではなくエラーとして扱うことです。
export type PaymentMode = 'test' | 'live';
export interface PaymentCredentials {
mode: PaymentMode;
secretKey: string;
publishableKey: string;
}
export function resolvePaymentCredentials(
settings: { isTestMode?: boolean | null; testKeys?: KeyPair; liveKeys?: KeyPair },
): PaymentCredentials {
if (settings.isTestMode === undefined || settings.isTestMode === null) {
throw new ConfigurationError(
'PAYMENT_MODE_UNRESOLVED',
'isTestMode must be persisted explicitly; refusing to default to live keys',
);
}
const mode: PaymentMode = settings.isTestMode ? 'test' : 'live';
const keys = mode === 'test' ? settings.testKeys : settings.liveKeys;
if (!keys) {
throw new ConfigurationError('PAYMENT_KEYS_MISSING', `no ${mode} keys configured`);
}
return { mode, secretKey: keys.secretKey, publishableKey: keys.publishableKey };
}このルールは一般化できます。boolean が安全な経路と危険な経路を選択する場合、undefined は危険な経路ではなくエラーでなければなりません。
5. 暗黙のコンテキスト解決を削除する
複数の施設向けに資格情報をキャッシュする token manager には、便利なオーバーロードがありました。呼び出し側が facilityId を省略すると、周囲の state から現在の施設を「自動解決」するというものです。シングルテナントのセッションではこれで動きます。しかし複数施設を扱う管理者セッションでは、誤った物件向けに発行されたトークンを渡してしまう可能性があります。親切な API 表面を持ったクロステナントの資格情報漏洩です。
修正はオーバーロードを完全に削除し、パラメータを必須にすることでした。
export class TokenManager {
private readonly cache = new Map<string, CachedToken>();
async getAccessToken(facilityId: string): Promise<string> {
if (!facilityId) {
throw new UnlockOSError(
'FACILITY_ID_REQUIRED',
'facilityId is required; ambient resolution was removed to prevent cross-tenant token reuse',
);
}
const cached = this.cache.get(facilityId);
if (cached && cached.expiresAt - Date.now() > REFRESH_SKEW_MS) {
return cached.accessToken;
}
const issued = await this.refresh(facilityId);
this.cache.set(facilityId, issued);
return issued.accessToken;
}
invalidate(facilityId: string): void {
this.cache.delete(facilityId);
}
}キャッシュがグローバルではなくテナント単位でキー付けされている点に注目してください。共有のキャッシュスロットは、同じバグが別の衣装を着ているだけです。また invalidate もテナント単位なので、ある連携を切断しても無関係なセッションを吹き飛ばすことはありません。
6. 認可: 経路を列挙し、理由を返す
認可チェックを広げる作業は、403 のバグが「うっかり直される」場所です。予約を作成したゲストしか認識しない所有者チェックのせいで、物件オペレーターがキャンセルしようとすると 403 が返っていました。安易なパッチは述語を緩めることです。より安全なパッチは、各許可経路を明示的かつ観測可能にすることです。
export type AuthzDecision =
| { allowed: true; via: 'reservation_guest' | 'facility_operator' | 'platform_admin' }
| { allowed: false; reason: 'not_found' | 'not_owner' | 'facility_mismatch' };
export async function verifyReservationAccess(
ctx: RequestContext,
reservationId: string,
): Promise<AuthzDecision> {
const reservation = await ctx.db.findReservation(reservationId);
if (!reservation) return { allowed: false, reason: 'not_found' };
if (ctx.actor.kind === 'platform_admin') {
return { allowed: true, via: 'platform_admin' };
}
if (ctx.actor.kind === 'guest' && reservation.guestId === ctx.actor.id) {
return { allowed: true, via: 'reservation_guest' };
}
if (ctx.actor.kind === 'operator') {
return ctx.actor.facilityIds.includes(reservation.facilityId)
? { allowed: true, via: 'facility_operator' }
: { allowed: false, reason: 'facility_mismatch' };
}
return { allowed: false, reason: 'not_owner' };
}判別可能なユニオン(discriminated union)は二重に元が取れます。呼び出し側は両方の分岐を扱わざるを得ず(コンパイラが強制します)、via / reason フィールドはそのまま監査ログへ流し込めるため、「誰がこの予約をキャンセルでき、どのルールに基づいていたのか?」を事後に答えられます。
const decision = await verifyReservationAccess(ctx, reservationId);
await ctx.audit.record({
action: 'reservation.cancel',
reservationId,
actorId: ctx.actor.id,
outcome: decision.allowed ? 'allowed' : 'denied',
rule: decision.allowed ? decision.via : decision.reason,
});
if (!decision.allowed) throw new ForbiddenError(decision.reason);オペレーターのアクセスは facilityIds によってスコープされます。物件 A のオペレーターは、物件 B に対しては(正確な理由 facility_mismatch とともに)拒否されます。ロールを広げること自体は問題ありませんが、スコープの述語なしに広げるのは問題です。
7. 識別子はデータベースに到達する前に検証する
フィーチャーフラグのオーバーライドは user_id と organization_id で解決されます。UI が非テナントのケースに対して "platform" のようなセンチネル文字列を合成していたため、オーバーライドのクエリは UUID ではない値を受け取っていました。Postgres は 22P02 invalid input syntax for type uuid で拒否し、リゾルバがそのエラーを catch し、catch ブロックの実装次第でフラグがフェイルオープンするか、ページ全体がエラーになります。
2つの修正を同時に適用しました。どちらも真似する価値があります。
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value);
}
export async function resolveOverride(
scope: { userId?: string | null; organizationId?: string | null },
): Promise<Override | null> {
const filters: OverrideFilter[] = [];
if (isUuid(scope.userId)) filters.push({ column: 'user_id', value: scope.userId });
if (isUuid(scope.organizationId)) {
filters.push({ column: 'organization_id', value: scope.organizationId });
}
if (filters.length === 0) return null;
return queryOverrides(filters);
}- クラッシュせずスキップする。 不正な形式のスコープ識別子は「適用されるオーバーライドはない」を意味し、フラグのデフォルトへ解決されます。例外パスではなく決定論的な結果になります。
- センチネルを発生源から取り除く。 「組織なし」をマジック文字列として UUID 型のフィールドに埋め込むのは、型システムに対する嘘です。
nullと別個のscope: 'platform' | 'organization'判別子を使えば、型は正直なままです。
付随するテストの変更は小さいですが示唆に富みます。オーバーライド解決のテストは、"user-1" のような文字列ではなく実際の UUID を使うよう書き換えられました。本番スキーマが拒否するようなデータ形状を使うテストは、フィクションを検証しているに過ぎません。
const USER_A = '3f1c2a7e-9b4d-4c2f-8a51-0b7d6e5f4a3c';
const ORG_A = '8d2e4b1a-6c3f-4e5d-9a7b-1c2d3e4f5a6b';
it('prefers a user-scoped override over an organization-scoped one', async () => {
await seedOverride({ flag: 'booking.grid_view', userId: USER_A, enabled: true });
await seedOverride({ flag: 'booking.grid_view', organizationId: ORG_A, enabled: false });
const result = await resolveFlag('booking.grid_view', { userId: USER_A, organizationId: ORG_A });
expect(result).toEqual({ enabled: true, source: 'user_override' });
});8. ゲートはメニューではなくルートに置く
メニュー項目をフィーチャーフラグの裏に隠すのは UX の判断です。アクセス制御ではありません。URL を直接入力すればルートには到達できます。ゲートはナビゲーションが解決される場所で強制し、さらにサーバー側でも再度強制しましょう。
export function createFlagGuard(flags: FlagResolver) {
return async (route: RouteDefinition, ctx: RequestContext) => {
if (!route.requiredFlag) return { proceed: true } as const;
const { enabled } = await flags.resolve(route.requiredFlag, {
userId: ctx.actor.id,
organizationId: ctx.actor.organizationId,
facilityId: ctx.facilityId,
});
if (enabled) return { proceed: true } as const;
return { proceed: false, redirectTo: '/not-available' } as const;
};
}リゾルバは完全なスコープ(ユーザー、組織、施設)を受け取ります。これにより、施設レベルのオーバーライドが組織レベルのデフォルトによって迂回されることがなくなり、優先順位がちょうど1箇所で定義され、呼び出し側ごとに再導出されることもなくなります。
9. スキーマ変更にもコードと同じパイプラインを
最後に、地味ですが重要な項目です。データベースマイグレーションを CI/CD に移行しました。手作業で適用されるその場限りのマイグレーションこそ、ステージングには存在するのに本番には存在しないポリシーを生む原因です。RLS にとってこれは最悪の障害モードです。なぜなら、アプリケーションは動き続ける一方で、ある環境だけ分離が静かに消失するからです。
set -euo pipefail
supabase db lint --level warning
supabase db diff --linked --schema public > /tmp/drift.sql
if [ -s /tmp/drift.sql ]; then
echo "::error::linked database has drifted from migrations"
cat /tmp/drift.sql
exit 1
fi
supabase migration up --linked重要なのはドリフトチェックの行です。これは「誰かが本番を手作業で変更した」という不可視の状態を、ビルド失敗という可視の事象に変えます。ローカルの pre-commit フックにある補助的なガードは、リンクされたプロジェクト参照が現在の worktree と一致することを検証するため、複数ブランチをチェックアウトしている開発者が誤った環境へマイグレーションを向けることを防ぎます。
まとめ
これらすべての変更に通底するものは同じです。システムが推測しなければならない経路を取り除くこと。
| 推測 | 置き換え |
|---|---|
| 可変なテーブルから読む管理者ステータス | 署名済み JWT クレーム、coalesce(..., false) |
| アプリケーション層でのテナントフィルタリング | using + with check を備えた RLS |
| ビューが所有者権限で実行される | security_invoker = on |
欠落したシークレットが '' になる |
モジュールロード時に throw |
| 未定義のテストモードフラグが本番キーにフォールバック | 未解決のモードで throw |
| 周囲の state からの施設解決 | 必須の facilityId パラメータ |
| UUID でないセンチネルがデータベースに到達 | 検証してスキップし、センチネルを廃止 |
| メニューだけのフィーチャーゲーティング | ルートガード + サーバー側チェック |
| 手作業で適用するマイグレーション | ドリフト検出付きのパイプライン |
どれも小さく、地味な変更です。しかし合わせると、SDK が「このドアは開いてよい」と判断したとき、その結論に至ったコードパスはちょうど1つだけであり、それが監査ログに名前付きで記録され、いかなる環境の設定ミスもそれを広げ得なかった、ということを意味します。