ITの窓辺から

三流IT技術者の日常

DjangoでLDAP認証 その5 パーミッションによる機能制限

前回ではようやくDjangoアプリケーションの認証をActive Directoryを使用したLDAP認証に置き換えることができました。今回からはアクセス制御、いわゆる認可に関してやっていきたいと思います。

realizeznsg.hatenablog.com

相変わらずの注意書きですが、この記事はWebアプリケーションとDjangoの初心者が調べたことをメモするものであり、正しい解説記事を目的としていないのでご注意ください。

環境は以下のとおりです。
OS : macOS(intel CPU) Big Sur
Python : バージョン3.8.5 (venv環境)
Django : バージョン3.2.5
Webサーバ : Djangoに組み込みのもの
ldap3 : バージョン2.9.1
LDAPサーバ : Active Directory (Windows Server2016)

意図せずやってたなんちゃって認可

一連のLDAP認証の動作検証を行う中で適当な掲示板ライクのCRUD操作を基本としたアプリを作りまして、その中に作成した記事の編集を行うためのビューがあります。そのビューはURLディスパッチ後に単純に記事の識別子を受け取って、その記事のモデルオブジェクトを取得します。このビューを起動した時のログインユーザが記事のモデルオブジェクトに格納されている記事作成者と一致しない場合、HTTPレスポンスとして編集ができないことを返すような処理を入れていました。これも認証と分離されたある種の認可だと思います。ただこの処理を1個ずつ書かないといけないのか?と疑問が出ました。

Djangoにはグループという概念もあるのでユーザをグループに所属させることでグループベースの制御を行うことである程度簡略化することもできると思います。しかし、私の会社では30以上の部があります。部レベルでDjangoの操作権限を変える場合、if文の条件やif文の数たるやすごいことになり、さらにそれが認証、認可が必要なビューの数分乗じられることを考えると、実装はともかくその後のメンテナンスが非常に面倒になることが想像されます。

このあたりをうまいことやる仕組みがDjango側にあるに違いない、と調べてみたものの、結論としては夢想していた良い解にはたどり着けませんでした。とはいえ後述しますがDjangoにはパーミッションという考え方があり、これを使うことでビュー単位以外のアクセス制御ができることがわかったので、これについて調べた内容をこれからの記事で書いていきたいと思います。

ところでDjangoにおいていわゆるビジネスロジックはどこに書くのが適切なのか、という疑問にぶつかりました。このあたりは一旦気にせずに認可の話だけやっていこうと思います。フレームワークの使い方という点では本質的な話の一つだと思いますので、そのうちちゃんと考えてみたいと思います。

Djangoの認可

これも公式ドキュメントを見ても具体的にどうれば良いのかよくわからなかったので、公式ドキュメントに書いてあったクラスやメソッドから調べてみました。そこで、まずDjangoが提供している認可機能の考え方は端的に言うとビジネスロジック向けのデータモデルの複雑性にあまり干渉せずにビュー実行権限付与とテンプレートの要素の表示制限行う、というもののように読めました。

先程触れたとおり、Djangoにはパーミッションという概念があります。パーミッションをユーザに割り当てることでget_userメソッドを必要とするページを表示する時に、そのユーザがそのパーミッションを持っているかどうかを判定して動作を変えるようなブール値のように機能します。

ユーザーモデルに権限を示すフィールドを作り、その値をベースに処理を作る手もあると思いますが、ちょっとした変更のたびにユーザーモデルを更新する必要があり、変更時のバグの混入やテスト漏れの発生確率がどんどん上がっていくと思われます。その点、パーミッションを使うことでユーザーモデルの変更なしでビューとテンプレートの動作を変えられるので、コードの保守性が上がると考えられます。

しかし前述の通りでビューやテンプレートでパーミッションごとに処理を分岐させるところの手間については特にフォローはなく、分岐処理がひたすら増えるというところには変わりなさそうです。勝手に夢想していたのは、データモデルから取り出し可能なデータをパーミッションで制御できないか、というものでした。データモデルに対してパーミッションでフィルタできればビューやテンプレートではパーミッションをあまり気にしなくても良いように思われるからです。

ただ、この考え方はたまたま今回単純なCRUD機能に特化したアプリを作ったからそうなったのかもしれません。ある事業におけるインプットからアウトプットのうちどの部分をDjangoに任せるかで最適な認可機能は変わると思いますし、そこまで広く語れるほど私はDjangoにもWebアプリケーションにも詳しくないので、やはりこれは一旦そういうものだと思って調査を行っておこうと思います。

基本事項の調査

パーミッションの定義

まずstartappでDjangoアプリケーションを作成した時点で標準のパーミッションが作成されます。しかしこのパーミッションDjango管理画面でのCRUD権限のためのパーミッションのようなので、基本的にはパーミッションは追加で自作することになります。パーミッションの定義はモデルクラス単位で行います。具体的にはモデルクラス内のMetaクラスで以下のように定義します。

class Meta:
    permissions = (
        ('perm1', 'Custom Permission 1'),
        ('perm2', 'Custom Permission 2')
    )

パーミッションの定義は二次元タプルで表現され、左側がパーミッション、右側パーミッション名です。使い分けがよくわかりませんが。この定義自体は特に意味はなく、パーミッションが定義されているテーブルauth_permissionsにレコードがINSERTされるだけです(当然migrateが必要です)。

パーミッションとユーザの紐付け

作成したパーミッションをユーザに紐付けします。一番単純な紐付け方法はDjango管理画面で手動でユーザにパーミッションを割り当てる方法です。Django管理画面のユーザーモデルのページでユーザを選択すると先程作成したパーミッションが表示されます。

ただ、実際には手動での割当は色々と微妙なので自動的に割り当てることを考えます。今回はLDAP認証をベースとしているので、LDAPサーバ上のアカウントの状態に応じて自動的にパーミッションを割り当てる方式を取ります。具体的には以下のような実装にしようと思います。

  • Django操作権限1と2を分類するためのセキュリティグループをActive Directory上に作成してユーザを所属させる。尚、今回は権限2は権限1を全て包含し、権限2のみ固有の機能を使用可能とする。
  • authenticateメソッドで認証が行われる際、ユーザのセキュリティグループを調べて当てはまるパーミッションを割り当てる。

余談ですが、そもそも認証情報があっていても特定のセキュリティグループに所属していなければ認証させないという当初要件の実装を失念していました。これは次の記事で実装したいと思います。具体的なコードは一通り説明を書いてからにします。

パーミッションの判定

次は実際のアプリケーション動作時にパーミッションに応じた処理を行います。パーミッションがないと実行できない処理にパーミッション保持判定を行うif文をつけます。Djangoでは認証後にget_userメソッドにより取得されるユーザーモデルオブジェクトが、どのパーミッションを持っているか、指定したパーミッションを持っているかを評価する方法がありました。

ユーザーモデルオブジェクトが持っているパーミッションの一覧は<ユーザーモデル>.user_permissions.all()で取得できます。返り値はQueryset型でした。Djangoにおけるマネージャをまだ正しく理解していないのですが、Pythonのinspectでユーザーモデルオブジェクトを調べたらuser_permissionsという項目があり、マネージャに関係しているような出力のされぶりだったので、all()をつけてみたらうまく返ってきました、といういい加減な調査です。

ユーザーモデルオブジェクトが指定したパーミッションを持っているかはユーザーモデルのメソッドhas_perm(<パーミッション名>)で取得できます。TrueかFalseが返ってきます。尚、パーミッションを付与した後に再度ユーザーモデルオブジェクトをユーザーモデルから作成しないと実体には反映されないのでご注意を。

それぞれ実際には以下のように使います。変数userにはユーザーモデルオブジェクトが格納されています。

一覧の取得

user.user_permissions.all()
<QuerySet [<パーミッションの一覧>]>

特定のパーミッションの保持判定

user.has_perm('app1.perm1')
True

パーミッションの指定は<アプリケーション名>.<パーミッション名>で指定します。

実装

パーミッションに応じた処理の分岐はビューとテンプレートで行えます。この記事ではビューでの処理分岐を記載します。テンプレートでの分岐はテンプレートの知識整理も兼ねて別記事にて今度まとめます。

と言っても大したことはありません。

@login_required
def limited_user_function_view(request):
    user = request.user
    if user.has_perm('app1.perm2'):
        正規処理
    else:
        return HttpResponseForbidden()

ここではapp1に対するパーミッションperm2が付与されていないとこのビューの正規処理は実行できません。

所感

上記の実装を作る時、元々作っていたデータモデルとは別にデータモデルを作成し、ManyToManyFieldでつなぎました。この関数は別のデータモデルを操作するための機能です。perm2は元々のデータモデルで作っていたパーミッションですが別のデータモデルに対する操作の制御にも使えます。パーミッションがモデルごとに定義可能なことを考えると、これは本来の用途とは異なる使い方なのだろうかと思う次第です。少しGoogle先生に聞いてみましたが明確な解答は見つけられず。

パーミッションによる制御もできたので、次で本来の要件を全て満たすコードを作成します。LDAPサーバからセキュリティグループを取り出し、それに対応したパーミッションを自動的に付与することと、特定のセキュリティグループを持っていないとDjangoの認証が成功しない、という2つの機能を追加します。

今回のLDAP調査でDjangoのそもそもの部分で全然理解ができていなかったことが結構分かってきたので中々ためになりました。