WPFアプリの権限管理:ユーザー別に画面アクセスを制限するRBAC/Claims実装ガイド

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.NavigateNavigate呼び出しをAppNavigatorに一本化最初に統制しやすい。漏れゼロにしやすい
Windowを直接開くWindow生成をファクトリ/サービス経由にするnew Window() を禁止ルール化すると強い
Prism RegionINavigationService相当のラッパー、またはナビゲーション要求の発行箇所「遷移要求を出す側」を統制できるかが勝負

ステップ:操作(コマンド)側の再チェックを必ず入れる

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.DeleteICommand(CanExecute/Execute)同一画面内で操作差を出す
データ条件StoreIdサービス層(フィルタ/取得条件)見えるデータを絞る

権限表(Excelで管理)をやめて「リポジトリで管理」するコツ

権限表をExcelで別管理するのは、最初は楽ですが、更新漏れや反映遅れで事故が起きがちです。現場でおすすめなのは、少なくとも次のどれかに寄せることです。

  • コード内のマッピング(PageRulesやPermission定数)として管理し、PRレビュー対象にする
  • JSON/YAMLなど設定ファイルとしてリポジトリに置き、起動時に読み込む
  • 管理画面でサーバー側に登録し、クライアントはキャッシュして参照(規模が大きい場合)

例として、権限表を「一覧で見える」形にしておくと、開発と運用が噛み合いやすいです。

画面/機能必要ロール必要権限(perm)備考
管理ページAdminPage.Admin入口で拒否
受注一覧ページUser, AdminPage.Orders閲覧は可
受注編集User, AdminOrder.Editボタンと実行で二重チェック
受注削除AdminOrder.Delete監査ログ必須
エクスポートUser, AdminFeature.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の出し分けを加えると、セキュリティと使いやすさを両立できます。画面や機能が増えても破綻しないよう、権限判定を一点に集約し、「権限表がコードと同じリポジトリで追える」状態を目指すのが近道です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次