
はじめに
「本人アカウント」と「権限レベル付きの委任アカウント」が同じサブリソースを操作するシステムでは、権限ごとにできる操作の境界をどう管理するかが地味に難しくなります。委任アカウントに view / approve / edit のような段階的な権限を持たせるケースは珍しくありませんが、機能を1つ追加するたびに権限判定のロジックをあちこちに書き散らかしてしまうと、どこかで権限漏れが起きるリスクが高まります。
今回は、これまで委任アカウント側は「承認」しかできなかった承認制サブリソースに対して、edit 権限を持つ委任アカウントには「作成」も許可する、という機能追加を題材にします。ポイントは、承認処理で先に実装済みだった権限チェックのコアロジックを、作成処理でもそのまま再利用して認可を一本化しているところです。新規に権限判定を書き起こすのではなく、既存の関数を呼ぶだけで済むように設計を寄せていく過程を見ていきます。
こんな人におすすめ
- 本人アカウントと委任・代理アカウントが混在するシステムを設計・実装している方
- 権限レベル(view / approve / edit など)ごとの操作境界をコードでどう表現するか悩んでいる方
- ルーティング層とサービス層で例外の意味をどう分けるか整理したい方
vi.mockを使ったテストで、実装側のexport変更にモックが追従せず静かに壊れた経験がある方
本論
権限チェック関数はアクションをまたいで再利用する
承認処理のために実装済みだった requireDelegatedEditPermission を、作成処理からもそのまま呼び出します。権限境界のロジック(誰が edit で誰が view/approve か)を承認用・作成用で二重管理しないことで、片方だけ改修して権限漏れが起きるリスクを消せます。
export async function createDelegatedSubResourceRecord(
db: AppDatabase,
actorId: string | number,
targetUserId: string | number,
input: SubResourceInput,
linkId: number,
timestamp: string,
) {
await requireDelegatedEditPermission(db, actorId, targetUserId, 'edit', linkId)
return createSubResourceRecord(db, targetUserId, input, actorId, timestamp)
}
サービス層の関数はこれだけです。権限判定を新たに書くのではなく、既存の関数呼び出しを1行足すだけで機能追加が完結しています。
ルーティング層とサービス層で例外の意味を分ける
ルーティング層では「委任リンクが存在し、有効(active)か」だけを見て、見つからなければ404を返します。実際の権限(edit/approve/view の境界)判定はサービス層に委譲し、権限不足は403として伝播させます。
if (request.method === 'POST' && url.pathname === '/api/delegated/sub-resources') {
// 作成も承認と同じく edit 権限の境界だけで許可する無料機能。
// entitlement(課金プラン)ゲートは要さない設計判断。
const authContext = await requireAuth(context)
const actor = await requireDelegatedActorRow(db, authContext.auth.id)
const payload = await readValidatedJsonBody(request, createPayloadSchema)
const link = await findLinkForActor(db, actor.id, payload.linkId)
if (!link || link.status !== 'active') {
throw new AppError({ code: 'link_not_found', status: 404, userMessage: 'リンクが見つかりません。' })
}
const resourceId = await createSubResourceRecord(
db, authContext.auth.id, link.target_user_id, payload, payload.linkId, nowIso(),
)
await appendAuditLog(db, authContext.auth.id, link.target_user_id, 'sub_resource_created', { title: payload.title })
await bumpBootstrapCacheVersions(db, [authContext.auth.id, link.target_user_id])
return json({ ok: true, resourceId }, { status: 201 })
}
「リンクの存在確認(404)」と「権限判定(403)」を層で分けておくと、テストも「リンクが無い場合」と「権限が足りない場合」を別ケースとして書きやすくなります。1つの関数の中で両方をまとめて判定してしまうと、テストのケース分けも曖昧になりがちです。
意図的に外している制約はコメントで明示する
承認・作成どちらも「有料プランのゲートは通さない無料機能」という設計判断を、コード内コメントとして残しています。後から読んだ人が「ゲートの実装漏れでは?」と誤解して、余計な修正を入れてしまうのを防ぐためです。権限チェックのように「あるはずのチェックが無い」コードは、意図的な省略なのか実装漏れなのか、コードだけでは区別がつきません。境界線の設計判断はコメントで残しておく価値があります。
vi.mock の全置換ファクトリはexportの追加を検知できない
モジュールをまるごと置き換える vi.mock(path, () => ({...})) 形式のモックは、実装側にexportを1つ増やしてもモック側が追従しておらず、「未定義」というエラーで静かに壊れることがあります。importOriginal を使って元のモジュールをspreadしつつ、必要な関数だけ上書きする部分モックに切り替えると、この手のズレを検知しやすくなります。
vi.mock('@worker/services/sub-resources', async (importOriginal) => {
const original = await importOriginal<typeof import('@worker/services/sub-resources')>()
return {
...original,
approveSubResourceRecord: mocks.approveSubResourceRecord,
createDelegatedSubResourceRecord: mocks.createDelegatedSubResourceRecord,
} satisfies typeof import('@worker/services/sub-resources')
})
satisfies で元の型と一致させておくことで、実装側の関数シグネチャが変わったときにもコンパイルエラーとして検知できるようになります。全置換のモックでは得られない安全性です。
つまづきやすいポイント
- 権限チェック関数を機能ごとに個別実装してしまうと、後から権限境界を変更するときに修正漏れが起きやすくなります。承認・作成のように似た操作は、同じ権限判定関数に寄せられないか先に検討する価値があります。
- 監査ログのアクション種別を1つ増やすと、表示用の分岐だけでなく、全アクション種別を列挙しているテストのfixtureリストも同時に直す必要があります。片方だけ直すと、テストが通っていても表示が欠けたままになることがあります。
- 同じUIフォームを本人向け・委任者向けで共用する場合、フォームコンポーネント自体は複製せず、送信ハンドラのコールバックだけ切り替える設計にすると、バリデーションやレイアウトの二重実装を避けられます。
まとめ
権限レベル付きの委任アカウントを扱うシステムでは、機能を1つ増やすたびに権限判定ロジックを書き起こすのではなく、既存の権限チェック関数を別のアクションからも呼び出せるように設計しておくことが、権限漏れを防ぐ近道になります。ルーティング層とサービス層で例外の意味を分けておくと、テストのケース分けも自然に整理されます。テストのモック戦略についても、全置換ではなく部分モックに寄せておくことで、実装側の変更をテストが検知できる状態を保ちやすくなります。

