C#のWPFアプリでページや機能が増えてくると、「このユーザーには管理画面を見せたくない」「閲覧専用は編集ボタンを押せないようにしたい」といった要件が必ず出てきます。本記事では、ロール/クレーム設計から画面遷移ガード、コマンド再チェック、UI出し分けまで、実務で破綻しにくい権限管理の作り方をまとめます。
WPFで「画面ごとのアクセス制御」を設計するときの前提
Webアプリではルーティングやコントローラに認可を掛けやすい一方、WPFはクライアントアプリなので、画面遷移(Frame/Window/PrismのRegionなど)を自分たちで組み立てます。そのため、権限管理も「画面の入口」と「操作の実行」を押さえないと、後から抜け道が生まれやすくなります。
実務で堅くするなら、次の三段構えが基本形です。
- 遷移前ガード:権限がないページへそもそも移動させない
- 操作ガード:ボタン等のコマンド実行時に再チェックして止める
- 表示の出し分け:見せるUIを絞って迷わせない(ただしセキュリティの主役ではない)
まず決める:RBAC(ロール)とクレーム(ポリシー)の使い分け
「管理者/一般/閲覧のみ」のように役割が明確ならRBAC(Role Based Access Control)が素直です。一方で「店舗=Aの人だけ」「契約プラン=Proならエクスポート可」のように条件が増えるなら、クレーム(Claims)やポリシー(Policy)に寄せると運用が楽になります。
| 方式 | 向いている要件 | メリット | 注意点 |
|---|---|---|---|
| RBAC(ロール) | 管理者/一般など役割が少数で固定 | 実装が簡単、説明しやすい | 条件が増えるとロール爆発しやすい |
| クレーム+ポリシー | 部署・店舗・プラン等の条件で制御したい | 拡張しやすく、条件を組み合わせやすい | 設計ルール(命名、優先順位)がないと混乱する |
この記事では、WPFで扱いやすいClaimsPrincipal(現在ユーザー)を中心に、RBACでもクレームでも対応できる形で説明します。
全体像:権限サービスを中心に「一点で判定」できる形にする
“気づいたところにifを書く”運用は、画面が増えた瞬間に破綻します。おすすめは、権限判定をIAuthorizationService(または同等の自前サービス)に集約し、UIとナビゲーションはその結果に従うだけ、という構造です。
| レイヤー | 責務 | 実装ポイント |
|---|---|---|
| ユーザーコンテキスト | ログイン結果(ロール/クレーム)を保持 | ClaimsPrincipalをアプリ全体で参照できるようにする |
| 権限判定サービス | 「この操作/ページは許可?」を一元判定 | IsInRole / HasClaim / Policy評価をまとめて提供 |
| 画面遷移ガード | 未許可ページへの遷移を拒否 | Navigateの入口をラップし、必ずガードを通す |
| コマンドガード | 操作実行時に再チェックしてブロック | CanExecuteとExecuteの両方でチェック |
| UI出し分け | メニュー/ボタンを見やすくする | Visibility / IsEnabledを権限にバインド |
ステップ:ログイン後にClaimsPrincipal(現在ユーザー)を確立する
まず、ログイン時にDBやIDプロバイダ(ADグループ、Entra IDなど)からロール/グループ/権限(クレーム)を取得し、アプリが参照する「現在ユーザー」を作ります。WPFではHTTPコンテキストがないため、アプリ内で保持するのが一般的です。
「グローバル変数」的に置くより、テストしやすいようにIUserContextのような抽象を挟むと、後で楽になります。
public interface IUserContext
{
System.Security.Claims.ClaimsPrincipal Current { get; }
event EventHandler? UserChanged;
void SetUser(System.Security.Claims.ClaimsPrincipal principal);
}
public sealed class UserContext : IUserContext
{
private System.Security.Claims.ClaimsPrincipal _current =
new(new System.Security.Claims.ClaimsIdentity()); // 未ログイン
public System.Security.Claims.ClaimsPrincipal Current => _current;
public event EventHandler? UserChanged;
public void SetUser(System.Security.Claims.ClaimsPrincipal principal)
{
_current = principal ?? new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity());
UserChanged?.Invoke(this, EventArgs.Empty);
}
}
DI(依存性注入)を使うなら、アプリ起動時にシングルトン登録して、ViewModel/サービスから参照できるようにします。
// Microsoft.Extensions.DependencyInjection を利用する例
var services = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
services.AddSingleton<IUserContext, UserContext>();
// 認可サービスやナビゲータも登録
// services.AddSingleton<IAuthorizationService, AuthorizationService>();
// services.AddSingleton<IAppNavigator, AppNavigator>();
ステップ:役割・権限の命名ルールを決めておく(運用で差が出る)
コードは書けても、運用で詰まるのは「権限名がバラバラ」「どこで何を見ているか分からない」問題です。おすすめは、以下のように命名ルールを固定することです。
- ロール:Admin / User / Viewer のように短く
- 権限(Permission):
Page.Admin/Feature.Export/Order.Editのようにドメイン+動詞で統一 - ポリシー(必要なら):
CanExportのように「やりたいこと」を名前にする
| 粒度 | 例 | おすすめ用途 |
|---|---|---|
| ページ単位 | Page.Admin | 画面自体を開かせない |
| 機能単位 | Order.Edit / Feature.Export | 同一画面内の一部ボタンだけ制御 |
| データ条件 | StoreId=123 のようなクレーム | 店舗/部署/契約プランなどABAC寄りの制御 |
ステップ:画面遷移ガード(ナビゲーション前チェック)を入れる
WPFのアクセス制御で最も効くのは、「遷移の入口を一本化」することです。Frame.NavigateやPrismのRequestNavigateをあちこちで直接呼び出すと、必ずガード漏れが出ます。
ページ要件を宣言する:属性で「必要ロール/権限」を持たせる
一番分かりやすいのは、ページクラスに必要条件を宣言し、遷移時に評価する方法です。ロールでも権限でも同じ構造にできます。
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class RequiresRoleAttribute : Attribute
{
public string Role { get; }
public RequiresRoleAttribute(string role) => Role = role;
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public sealed class RequiresPermissionAttribute : Attribute
{
public string Permission { get; }
public RequiresPermissionAttribute(string permission) => Permission = permission;
}
public interface IAuthorizationService
{
bool IsInRole(string role);
bool HasPermission(string permission);
}
public sealed class AuthorizationService : IAuthorizationService
{
private readonly IUserContext _userContext;
public AuthorizationService(IUserContext userContext) => _userContext = userContext;
public bool IsInRole(string role) => _userContext.Current.IsInRole(role);
public bool HasPermission(string permission) =>
_userContext.Current.HasClaim("perm", permission);
}
次に、Frame.Navigate を直接呼ばず、AppNavigatorを経由させます(クラス名はWPFのNavigationServiceと衝突しない名前にしておくのが無難です)。
public sealed class AppNavigator
{
private readonly System.Windows.Controls.Frame _frame;
private readonly IAuthorizationService _auth;
public AppNavigator(System.Windows.Controls.Frame frame, IAuthorizationService auth)
{
_frame = frame;
_auth = auth;
}
public bool NavigateTo<TPage>() where TPage : System.Windows.Controls.Page, new()
{
var pageType = typeof(TPage);
var requiredRoles = pageType
.GetCustomAttributes(typeof(RequiresRoleAttribute), true)
.Cast<RequiresRoleAttribute>()
.Select(a => a.Role)
.ToArray();
var requiredPerms = pageType
.GetCustomAttributes(typeof(RequiresPermissionAttribute), true)
.Cast<RequiresPermissionAttribute>()
.Select(a => a.Permission)
.ToArray();
// ここは要件でOR/ANDを決める(運用しやすいのは「ロールOR」「権限OR」から開始)
var roleOk = requiredRoles.Length == 0 || requiredRoles.Any(r => _auth.IsInRole(r));
var permOk = requiredPerms.Length == 0 || requiredPerms.Any(p => _auth.HasPermission(p));
if (roleOk && permOk)
return _frame.Navigate(new TPage());
System.Windows.MessageBox.Show("アクセス権限がありません。");
return false;
}
}
ページ側は宣言するだけです。
[RequiresRole("Admin")]
[RequiresPermission("Page.Admin")]
public partial class AdminPage : System.Windows.Controls.Page
{
public AdminPage() { InitializeComponent(); }
}
属性が嫌な場合:ページ要件を「権限表(マッピング)」で管理する
属性は便利ですが、「ページクラスに認可が散らばるのが嫌」「権限表をまとめて見たい」という現場も多いです。その場合、ページキー(文字列やenum)を使い、設定として集約します。
public sealed record PageRule(string PageKey, string[] AnyRoles, string[] AnyPermissions);
public sealed class PageRules
{
private readonly Dictionary<string, PageRule> _rules = new();
public void Add(PageRule rule) => _rules[rule.PageKey] = rule;
public bool TryGet(string key, out PageRule rule) => _rules.TryGetValue(key, out rule!);
}
この形式にすると、「権限表=コード(同じリポジトリで管理)」が実現しやすく、レビュワーも確認しやすいです。特に規模が大きい場合は、属性よりマッピングが管理しやすいことがあります。
Prism(Region Navigation)を使う場合のガードの入れどころ
Prismを利用しているなら「RequestNavigateの前」に判定できる場所を作るのがポイントです。考え方は同じで、遷移要求を発行するサービスを一段ラップして、そこに認可チェックを入れます。
| ナビゲーション方式 | ガードを入れやすい場所 | 実務メモ |
|---|---|---|
| Frame.Navigate | Navigate呼び出しをAppNavigatorに一本化 | 最初に統制しやすい。漏れゼロにしやすい |
| Windowを直接開く | Window生成をファクトリ/サービス経由にする | new Window() を禁止ルール化すると強い |
| Prism Region | INavigationService相当のラッパー、またはナビゲーション要求の発行箇所 | 「遷移要求を出す側」を統制できるかが勝負 |
ステップ:操作(コマンド)側の再チェックを必ず入れる
UIを非表示にしても、ショートカットキー、別導線、遷移漏れなどでコマンドが呼ばれる可能性は残ります。さらに「画面が開けた=全操作OK」ではなく、「画面は見せるが編集は不可」といった要件もよくあります。そこで、CanExecuteで状態制御しつつ、Executeでも最後に止めるのが堅実です。
権限付きコマンド(RelayCommand)の例
MVVMでよく使うRelayCommand/DelegateCommandの前に、権限判定を噛ませます。
public sealed class AuthorizedCommand : System.Windows.Input.ICommand
{
private readonly IAuthorizationService _auth;
private readonly string _permission;
private readonly Action _execute;
private readonly Func<bool>? _extraCanExecute;
public AuthorizedCommand(
IAuthorizationService auth,
string permission,
Action execute,
Func<bool>? extraCanExecute = null)
{
_auth = auth;
_permission = permission;
_execute = execute;
_extraCanExecute = extraCanExecute;
}
public bool CanExecute(object? parameter)
=> _auth.HasPermission(_permission) && (_extraCanExecute?.Invoke() ?? true);
public void Execute(object? parameter)
{
// 実行直前にも再チェック(ここが重要)
if (!_auth.HasPermission(_permission))
{
System.Windows.MessageBox.Show("操作権限がありません。");
return;
}
_execute();
}
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged()
=> CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
ViewModel側はこうなります。
public sealed class OrdersViewModel
{
public System.Windows.Input.ICommand ExportCommand { get; }
public OrdersViewModel(IAuthorizationService auth)
{
ExportCommand = new AuthorizedCommand(
auth,
permission: "Feature.Export",
execute: Export,
extraCanExecute: () => CanExportByState);
}
private bool CanExportByState => true; // 例:画面状態や選択有無など
private void Export()
{
// 実処理
}
}
さらに実務では、ログインユーザーが変わったときにCanExecuteを更新したいので、UserChangedイベントでRaiseCanExecuteChangedを呼ぶようにすると気持ちよく動きます。
重要:クライアントだけで完結しない操作はサーバー側でも必ずチェック
DB更新、ファイル出力、外部API呼び出しなど「結果が残る」処理は、クライアント側の権限チェックだけでは不十分です。クライアントは改変・解析される可能性があるため、API/DBの入口でも同等の認可を掛けてください。WPF側のチェックは「誤操作防止」と「UI/UX改善」と割り切り、サーバー側に最終防衛線を置くのが現実的です。
ステップ:UIの出し分け(Visibility/IsEnabled)で迷子を減らす
メニューやボタンの表示を権限で変えると、使える人だけが必要な機能に集中でき、問い合わせも減ります。ただし、これは利便性のためであり、セキュリティ担保は「遷移ガード」「操作ガード」で行います。
ViewModelに権限フラグを用意する(素直で保守しやすい)
最も分かりやすいのは、ViewModelに CanShowAdminMenu のようなプロパティを持たせる方法です。権限サービスはViewModelで一回評価し、変更時に通知します。
public sealed class ShellViewModel : System.ComponentModel.INotifyPropertyChanged
{
private readonly IAuthorizationService _auth;
private readonly IUserContext _userContext;
public ShellViewModel(IAuthorizationService auth, IUserContext userContext)
{
_auth = auth;
_userContext = userContext;
_userContext.UserChanged += (_, __) => RaiseAll();
}
public bool CanShowAdminMenu => _auth.IsInRole("Admin") && _auth.HasPermission("Page.Admin");
public bool CanExport => _auth.HasPermission("Feature.Export");
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
private void RaiseAll()
{
PropertyChanged?.Invoke(this, new(nameof(CanShowAdminMenu)));
PropertyChanged?.Invoke(this, new(nameof(CanExport)));
}
}
XAMLではVisibilityに変換して使います(Converterはプロジェクト共通に1つ用意するのがおすすめです)。
<!-- XAML例(< > はHTMLの都合でエスケープしています) -->
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
</Window.Resources>
<MenuItem Header="管理"
Visibility="{Binding CanShowAdminMenu, Converter={StaticResource BoolToVis}}" />
<Button Content="エクスポート"
IsEnabled="{Binding CanExport}"
Command="{Binding ExportCommand}" />
UI側で権限判定をやりすぎない(責務を分ける)
Converterでロール判定やクレーム判定を直接行う作りは、XAMLが肥大化しがちです。ViewModelが「Can○○」を提供し、XAMLはそれを表示に反映するだけ、という線引きをすると、後から機能追加しても壊れにくいです。
ページ単位だけでなく「画面内の機能単位」で切る設計が効く
「このページは見せるが、削除だけは禁止」のような要件は頻出です。ページ権限だけで表現しようとすると、ページが増殖したり、ロールが細分化しすぎたりします。
おすすめは、次のようにPage(入口)とFeature(操作)を分けて設計することです。
| 分類 | 権限名例 | チェック箇所 | 狙い |
|---|---|---|---|
| 入口(ページ) | Page.Admin | ナビゲーションガード | そもそも開かせない |
| 操作(機能) | Order.Delete | ICommand(CanExecute/Execute) | 同一画面内で操作差を出す |
| データ条件 | StoreId | サービス層(フィルタ/取得条件) | 見えるデータを絞る |
権限表(Excelで管理)をやめて「リポジトリで管理」するコツ
権限表をExcelで別管理するのは、最初は楽ですが、更新漏れや反映遅れで事故が起きがちです。現場でおすすめなのは、少なくとも次のどれかに寄せることです。
- コード内のマッピング(PageRulesやPermission定数)として管理し、PRレビュー対象にする
- JSON/YAMLなど設定ファイルとしてリポジトリに置き、起動時に読み込む
- 管理画面でサーバー側に登録し、クライアントはキャッシュして参照(規模が大きい場合)
例として、権限表を「一覧で見える」形にしておくと、開発と運用が噛み合いやすいです。
| 画面/機能 | 必要ロール | 必要権限(perm) | 備考 |
|---|---|---|---|
| 管理ページ | Admin | Page.Admin | 入口で拒否 |
| 受注一覧ページ | User, Admin | Page.Orders | 閲覧は可 |
| 受注編集 | User, Admin | Order.Edit | ボタンと実行で二重チェック |
| 受注削除 | Admin | Order.Delete | 監査ログ必須 |
| エクスポート | User, Admin | Feature.Export | プラン条件があればクレームで追加制御 |
監査ログ:拒否したときほど情報を残す
アクセス拒否は、後から問い合わせが来やすいポイントです。拒否メッセージだけだと追跡できないので、最低限「誰が」「いつ」「どの画面/操作で」「どの権限が足りなかったか」を残せると運用が安定します。
- 画面遷移拒否:ユーザーID、ページ名、必要ロール/権限、実ロール/権限
- 操作拒否:コマンド名、対象ID(受注番号など)、理由
- 重要操作:成功ログも残す(監査要件がある業務だと必須)
テスト:権限パターンを単体テスト化すると破壊的変更に強くなる
WPFのUIテストは重くなりがちなので、まずは「権限判定サービス」と「ナビゲータの判定部分」を単体テストし、主要な権限パターン(Admin/User/Viewer)で期待通りになることを固定化するのが効果的です。
// 擬似的なClaimsPrincipalを作るヘルパー例
static System.Security.Claims.ClaimsPrincipal MakeUser(params (string type, string value)[] claims)
{
var identity = new System.Security.Claims.ClaimsIdentity("Test");
foreach (var c in claims)
identity.AddClaim(new System.Security.Claims.Claim(c.type, c.value));
return new System.Security.Claims.ClaimsPrincipal(identity);
}
// perm クレームに Feature.Export があるなら true などを検証する
// テストフレームワークは任意(xUnit/NUnit/MSTest)
「新しいページを追加したら、権限表(テスト)が落ちる」状態にしておくと、権限漏れをリリース前に潰せます。
よくある落とし穴と、現場で効く対策
| 落とし穴 | 起きがちな問題 | 対策 |
|---|---|---|
| ボタンを隠しただけ | 別導線やショートカットで実行される | CanExecute+Executeで二重チェック |
| Navigateが分散 | ガード漏れが必ず起きる | ナビゲーションをサービスに一本化 |
| ロールが増殖 | 運用が説明できなくなる | Page/Featureに分け、条件はクレームに寄せる |
| 権限の変更が反映されない | ログアウトしないと変わらない | ユーザーコンテキスト更新+CanExecute再評価の仕組み |
| 重要操作がクライアントだけで完結 | 改変されると突破されうる | サーバー/API/DB側で最終チェック |
実装のすすめ方(迷ったらこの順で固める)
- ロール/権限(perm)命名ルールを決め、ログイン時にClaimsPrincipalを作る
- ナビゲーションの入口を一本化し、ページ要件(属性 or マッピング)で遷移ガード
- 重要操作から順にAuthorizedCommand等でコマンド再チェックを入れる
- メニュー/ボタンのVisibilityやIsEnabledでUIを出し分ける
- 拒否ログ、重要操作ログ、テスト(代表ユーザーの権限パターン)を整備する
まとめ:WPFの権限管理は「入口」と「実行」を守ると崩れない
WPFアプリでユーザーごとに画面アクセスを制限するなら、ロール/クレームの設計を先に固め、遷移前ガードとコマンド実行時の再チェックを必ず入れるのが実用的です。その上でUIの出し分けを加えると、セキュリティと使いやすさを両立できます。画面や機能が増えても破綻しないよう、権限判定を一点に集約し、「権限表がコードと同じリポジトリで追える」状態を目指すのが近道です。

コメント