Tauri v2の最小権限設計: capability・permission・scopeを分ける
Tauri v2でウィンドウ、プラグイン、ファイル経路を最小権限で設計するために、capability、permission、scopeの役割と実装を解説します。
権限設定は機能一覧ではなく、信頼境界の地図だ
Tauri v2の権限設定を「動かない機能を通すJSON」として扱うと、default権限を重ね、いつの間にか画面が端末の広い領域へ到達するアプリになります。設定は、どのウィンドウが、どのプラグインまたはcommandを、どんな入力と対象範囲で呼べるかを記録する信頼境界の地図です。
最小権限は機能を減らすことではありません。ユーザーが必要な作業を完了できる一方、不要な画面、経路、操作には権限を与えないことです。ファイル保存、外部URL、クリップボード、通知、複数ウィンドウを追加するたび、この地図を更新します。
capability、permission、scopeを一文ずつ区別する
三つは似て見えますが、答える問いが異なります。
| 概念 | 問い | 例 |
|---|---|---|
| capability | どのウィンドウ/WebViewへ、どの権限セットを渡すか | mainだけにノート編集の機能を与える |
| permission | そのプラグイン/APIのどの操作を許すか | dialog:allow-open、テキストファイル読取り |
| scope | 許した操作は、どの値・パス・URLまでに及ぶか | $APPDATA/notes/*.mdだけ書込み |
capabilityは配布先、permissionは動詞、scopeは対象の範囲です。fs:allow-write-text-fileを許しても、scopeで書込み先を絞らなければ最小権限ではありません。逆にscopeだけ細かくても、外部ログイン画面へそのcapabilityを付ければ不要な到達経路が生まれます。
最も小さい機能単位から始める理由
「ノートを保存する」ではなく、保存フローを分けます。ユーザーが保存先を選ぶにはdialog、アプリ管理フォルダーへ下書きを書くにはfs、作成済みノートの一覧を出すには読取りが必要です。それぞれを分けると、キャンセル、拒否、既存ファイル、外部パスの失敗をUIで説明できます。
選ぶ → dialog権限
読込む → fs read権限 + 読取りscope
保存する → fs write権限 + 書込みscope
一覧にする → directory read権限 + 限定scope
開発時に「一旦すべて許可」は便利に見えても、本番で必要な範囲を見失わせます。最小の一操作を通し、必要になったpermissionとscopeだけを足す方が、設定レビューも障害調査も容易です。
capabilityはウィンドウごとに分け、重複適用は意識的に行う
メイン編集画面、環境設定、ヘルプ、OAuthログインのウィンドウには同じ能力が必要とは限りません。とくに外部コンテンツを表示するウィンドウにローカルファイルやshellの権限を付けません。
// src-tauri/capabilities/main-editor.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-editor",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save",
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$APPDATA/notes/*.md" }] },
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$APPDATA/notes/*.md" }] }
]
}
// src-tauri/capabilities/help.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "help-window",
"windows": ["help"],
"permissions": ["core:default"]
}
同じpermissionを複数capabilityへ置く場合も、「両方に必要だから」と書ける状態にします。window labelはtitleと別の安定した識別子として管理し、ユーザー入力から生成しません。
permissionはコマンド名でなく影響範囲として読む
permission識別子は機能名に見えますが、実際にはOSへの影響を持ちます。fs:default、shell実行、任意URLを開くpermissionを追加する前に、誰がどの入力で何を実行できるかを書きます。プラグイン版によって識別子やschemaは変わり得るため、導入済みバージョンの生成schemaと公式文書を必ず確認してください。
| 要求 | よくある広すぎる許可 | 狭い代替 |
|---|---|---|
| ノート保存 | ファイルシステムのdefault | テキスト書込みとnotes配下scope |
| URLを開く | 任意URLをアプリ内表示 | 固定originの許可、その他は既定ブラウザ |
| ファイル選択 | 読書き一式 | dialog openだけを先に許可 |
| 設定保存 | ホームディレクトリ全体 | アプリデータ配下の専用ファイル |
scopeは許可の後に来る二つ目の質問だ
permissionが「書けるか」を答えるなら、scopeは「どこに書けるか」を答えます。パスscopeにはOS差、環境変数、ワイルドカード、シンボリックリンク、取消後のパスを含む現実の入力があります。開発者のホームで通るパスが、別ユーザー、別OS、サンドボックス、外部ドライブでも安全とは限りません。
{
"identifier": "fs:allow-write-text-file",
"allow": [{ "path": "$APPDATA/notes/*.md" }]
}
アプリがユーザー選択の任意ファイルを直接編集するなら、選択済みパスの再利用、拡張子・サイズ検証、削除・移動時のUI、パス正規化を明示します。scopeを広げることが唯一の解決ではありません。import時にアプリ領域へ複製し、内部データだけを編集すれば、バックアップや同期の境界も明快になります。
自作Rust commandは別の製品APIとして扱う
#[tauri::command]はフロントエンドから呼べるローカルAPIです。Rustで実装したから安全なのではなく、入力検証、認可、パス、エラー、ログを考える必要があります。
use std::path::{Path, PathBuf};
#[tauri::command]
fn save_note(app: tauri::AppHandle, name: String, body: String) -> Result<(), String> {
if !name.ends_with(".md") || name.contains('/') || name.contains('\\') {
return Err("Invalid note name".into());
}
if body.len() > 1_000_000 { return Err("Note is too large".into()); }
let base = app.path().app_data_dir().map_err(|e| e.to_string())?.join("notes");
let target: PathBuf = base.join(name);
if !target.starts_with(&base) { return Err("Invalid path".into()); }
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
std::fs::write(target, body).map_err(|e| e.to_string())
}
commandを追加したら、呼べるwindow、入力の最大量、失敗時にユーザーへ返す文言、診断ログに残す内容をレビューします。任意パス、任意SQL、任意shellコマンドのような汎用入口を作らず、製品操作ごとに狭いcommandを作ります。
# src-tauri/permissions/notes.toml
[[permission]]
identifier = "allow-save-note"
description = "Allows saving a validated note in the app data directory."
commands.allow = ["save_note"]
リモートコンテンツにローカル権限を既定で与えない
外部ヘルプ、OAuth、ユーザー生成HTML、Webページを表示するWebViewは、アプリ自身の編集画面と異なる信頼境界です。リモートコンテンツにmain capabilityを渡さないことを既定にし、必要な認証は既定ブラウザまたは権限を持たない専用windowで処理します。外部URLを開くAPIも、許可originと戻りURLを絞ります。
この原則はXSSや依存パッケージの脆弱性が起きた場合の被害範囲を狭めます。デザイン上同じ見た目でも、表示するコンテンツの出所が違えばcapabilityも違うべきです。
モバイルも視野に入れるならplatform分離から始める
Tauriのモバイル対応を検討しても、デスクトップのファイル、トレイ、複数windowをそのまま移植できるとは限りません。UI、ドメイン型、APIクライアントは共有し、権限とOS操作はplatform adapterへ分離します。Capacitorを併用する場合も、カメラ、プッシュ、共有はモバイル固有の契約として扱います。
core/ ドメイン、検証、API
ui/ 画面と状態
desktop/ Tauri command、capability、ファイル操作
mobile/ カメラ、通知、モバイル権限
権限エラーを解く順番が権限品質を決める
not allowedを見たら次の順で調べます。
- 呼出しているwindow labelに期待するcapabilityが適用されているか。
- 必要なプラグインpermissionが、導入済み版の識別子で与えられているか。
- 操作対象のパス、URL、入力がscopeに一致するか。
- dialogで選ぶ行為とfsで読書きする行為を混同していないか。
- それでも必要なら、製品要件を記録して最小のpermissionまたはscopeを追加できるか。
全許可を追加してエラーを消すのは、問題を先送りするだけです。失敗ケースをテストに残せば、新機能やプラグイン更新で境界が広がったことも検出できます。
配布前の最小権限チェックリスト
- 各capabilityに対象windowと目的が書かれているか。
- ファイル、URL、shell、クリップボードの権限は必要な操作・対象だけか。
- 外部/ユーザー生成コンテンツのwindowはローカル権限を持たないか。
- custom commandは入力、パス、サイズ、状態をRust側で検証するか。
- macOS、Windows、Linuxの対応範囲で、拒否、取消、パスなし、移動済みファイルを試したか。
- 誰がpermission追加をレビューし、リリース時に生成schemaを再確認するか決めたか。
セキュリティは権限ファイルだけで完成しない
capabilityは重要ですが、更新署名、依存関係の更新、CSP、認証トークンの保管、ログの秘匿、データ移行、OS別QAも必要です。権限ファイルはこれらを代替しません。ただし、端末能力の入口を明示して狭く保てるため、他の対策と合わせたとき被害範囲を管理しやすくなります。