Djangoには認可機能が標準で用意されています。
ただ、標準の認可機能の場合、モデルごとの認可判定は可能な一方で、オブジェクトごとの認可判定ができないようです。
Django のパーミッションフレームワークはオブジェクトパーミッション基盤を持っていますが、コアには実装されていません。これにより、オブジェクトパーミッションのチェックは常に False または空のリスト(実行されたチェックに応じていずれか)が返されます。
https://docs.djangoproject.com/ja/3.0/topics/auth/customizing/#handling-object-permissions
自作する方法はstackoverflowにありましたが、正直なところ自作したくはありません。
Steps to add model level permission in Django - Stack Overflow
そんな中、jbkingさんの「どじゃんご本#1」を読んだところ、オブジェクトごとに認可判定ができるライブラリとして
django-guardian
django-rules
の紹介がありました。
どじゃんご本 #1 - どじゃんご@jbking - BOOTH
違いとして、 django-guardian
はDBで制御、 django-rules
はルールベースでの制御とのことでした。
そこで、ルールベースでの制御とはどのようなものか気になったため、 django-rules
を試してみることにしました。
目次
環境
認可処理を試すシナリオ
今回は以下のシナリオに沿って試してみます。
ある会社にてお知らせを閲覧するWebアプリが必要になりました。
仕様は
- ある部門に向けたお知らせを作成する
- お知らせは部門間でまたがることはない
- 職務ごとにお知らせの閲覧範囲が異なる
- ログインしないとお知らせは閲覧できない
です。
職務は
- 一般
- 管理職
- システム管理者
の3種類です。
閲覧範囲は
- ログインしていない場合は、閲覧不可
- 一般は、自分の所属する部門のお知らせのみ閲覧可能
- 他部門は、タイトルのみ把握可能
- 役職者とシステム管理者は、すべてのお知らせを閲覧可能
です。
このシナリオを django-rules
を使ってDjangoで実装してみます。
実装方針
今回は
- 職務により、全てのお知らせを閲覧可能 or 一部のお知らせのみ閲覧可能が切り替わること
- 一部の職務では、所属部門のお知らせのみ閲覧できること(ただし、タイトルは閲覧可能)
という2つの観点で認可処理を考えることにします。
そのため、「それぞれの観点ごとに、ユーザークラスに対して制御用項目を用意する」方針にします。
- 1.については、
権限タイプ
を用意- 目的:職務ごとの権限範囲を指定するため
- 所属部門だけなのか、全体なのか
- 目的:職務ごとの権限範囲を指定するため
- 2.については
所属部門
を用意- 目的:どの部門のお知らせを見れるのかを判断するため
この方針に従い、 django-rules
を使って実装していきます。
なお、スペースの都合上、django-rulesまわりや必要だと思ったところのみ、Blogに記載します。
もし、ソースコード全体を眺めたい場合は、以下となります。
https://github.com/thinkAmi-sandbox/django-rules-sample
実装
Djangoプロジェクトとアプリの用意
$ pip install django rules $ django-admin startproject config . # ユーザーを管理するアプリ $ python manage.py startapp accounts # お知らせを管理するアプリ $ python manage.py startapp myapp
権限タイプを models.IntegerChoices
として用意
今回は
- システム管理者
- 管理職
- 一般
の3種類の権限を用意します。
Userモデルに権限タイプを紐付けるため、Django3から使えるようになった models.IntegerChoices
を使って定義します。
# accounts/models.py class PermissionType(models.IntegerChoices): ADMIN = (1, 'システム管理者') MANAGER = (2, '管理職') EMPLOYEE = (3, '一般')
部門モデルの作成
フィールドとして name
だけを用意しました。
# accounts/models.py class Section(models.Model): name = models.CharField(max_length=100)
標準Userモデルを差し替え
属性タイプ・部門をUserモデルに追加するため、標準のUserモデルから AbstractBaseUser
を拡張したモデルへと差し替えます。
なお、差し替えるモデルの内容は、 AbstractUser
から不要なものをコメントアウトした後、必要なものを追加する形にします。
必要なものは
- 権限タイプ(permission_type)
- 部門(section)
- 表示名 (display_name)
の3項目とします。
# accounts/models.py class User(AbstractBaseUser, PermissionsMixin): username = models.CharField(...) display_name = models.CharField(max_length=100) # Django3.0からはIntegerChoicesを使える permission_type = models.IntegerField(choices=PermissionType.choices, default=PermissionType.EMPLOYEE) section = models.ForeignKey('accounts.Section', on_delete=models.PROTECT) # ...
お知らせモデルの作成
部門に紐付けるため、 Section
モデルを外部キーとして用意します。
# myapp/models.py class News(models.Model): title = models.CharField(max_length=50, verbose_name='タイトル') content = models.CharField(max_length=100, verbose_name='内容') section = models.ForeignKey('accounts.Section', on_delete=models.PROTECT)
ログイン・ログアウト機能の作成
未ログインは閲覧不可、ログイン済は職務に応じて閲覧可能とするため、ログイン機能を作成します。
ログイン・ログアウトのViewは、Django標準のViewを使います。
また、ログイン画面・ログアウト画面のテンプレートは、今回手抜きをしてDjango adminのものを流用します。
Is there a built-in login template in Django? - Stack Overflow
そのため、ログイン・ログアウトまわりのViewはurls.pyだけで完結できます。
# accounts/urls.py from django.contrib.auth.views import LoginView, LogoutView from django.urls import path app_name = 'accounts' urlpatterns = [ path('login/', LoginView.as_view(template_name='admin/login.html'), name='login'), path('logout/', LogoutView.as_view(), name='logout'), ]
ログイン判定を行うミドルウェアを作成
今回、未ログイン時はどのお知らせも閲覧不可とします。
そのため、ログイン判定処理はミドルウェアにて一括で実行します。
もしログインしていない場合は、ログイン画面へと遷移します。
from django.shortcuts import redirect from django.urls import reverse class LoginRequiredMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): return self.get_response(request) def process_view(self, request, view_func, view_args, view_kwargs): # 全画面ログイン必須なので、ログインしてなかったらログイン画面へ遷移 if not request.user.is_authenticated and request.path != reverse('accounts:login'): return redirect('accounts:login')
権限の作成
権限タイプ用
指定したユーザーがどの権限タイプであるかを判定するルールを作成します。
https://github.com/dfunckt/django-rules#creating-predicates
ルールの判定処理は、デコレータ @rules.predicate
を付けた関数で行います。
関数の戻り値は
- True
- ルールを満たす(=権限あり)
- False
- ルールを満たさない(=権限なし)
- None
- ルールの判定をパスする(=決定に影響を及ぼさない)
のいずれかです。
例えば、与えられたユーザーが管理者権限を持つかどうかは以下のとおりです。
import rules from .models import PermissionType @rules.predicate def is_admin(user): return user.permission_type == PermissionType.ADMIN.value
なお、PermissionTypeクラスは models.IntegerChoices
のサブクラスなので、
PermissionType.ADMIN.value
とすることで ADMIN = (1, 'システム管理者')
の 1
が取得できます。
https://docs.djangoproject.com/ja/3.0/ref/models/fields/#enumeration-types
次に、 rules.add_perm
で、指定した権限にルールを割り当てます。
https://github.com/dfunckt/django-rules#setting-up-rules
rules.add_perm('accounts.admin', is_admin)
ちなみに、一つの権限に対して、複数のルールをすべて満たす(=AND条件)やいずれかを満たす(=OR条件)を割り当てることもできます。
https://github.com/dfunckt/django-rules#combining-predicates
例えば以下の場合は、is_admin
is_manager
is_employee
のいずれかがTrueであれば accounts.employee
の権限があるとみなされます。
rules.add_perm('accounts.employee', is_admin | is_manager | is_employee)
権限タイプ用はこんな感じになります。
# accounts/rules.py import rules from .models import PermissionType @rules.predicate def is_admin(user): return user.permission_type == PermissionType.ADMIN.value @rules.predicate def is_manager(user): return user.permission_type == PermissionType.MANAGER.value @rules.predicate def is_employee(user): return user.permission_type == PermissionType.EMPLOYEE.value rules.add_perm('accounts.admin', is_admin) rules.add_perm('accounts.manager', is_admin | is_manager) rules.add_perm('accounts.employee', is_admin | is_manager | is_employee)
所属部門用
同様にして作成します。
is_same_section()
の第二引数 obj
は、お知らせクラス(News
)のオブジェクトが入ってくる想定です。
# myapp/rules.py import rules from accounts.rules import is_admin, is_manager @rules.predicate def is_same_section(user, obj): return user.section == obj.section rules.add_perm('myapp.same_section', is_same_section | is_admin | is_manager)
テンプレートの作成
ベーステンプレート
ログインしているユーザーが見えるよう、以下のようなベーステンプレートを用意します。
<!-- templates/base.html --> {% block load %}{% endblock %} <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>{% block title %}{% endblock %}</title> </head> <body> <p>ログインユーザ:{{ request.user.display_name }}</p> <p>所属部署:{{ request.user.section.name }}</p> <p>権限種類:{{ request.user.permission_type }}</p> <a href="{% url 'accounts:logout' %}">ログアウト</a> {% block content %}{% endblock %} </body> </html>
一覧画面用(django-rulesのテンプレートタグを試す)
一覧画面ではNewsの id
と title
だけ表示します。
この画面は一般権限でも閲覧可能としています*1。
django-rules
のテンプレートタグ has_perm
の動作確認をするため、権限ごとに li
タグの表示/非表示を切り替えます。https://github.com/dfunckt/django-rules#permissions-and-rules-in-templates
例えば、システム管理者だけが閲覧可能にする方法です。
{% load rules %} ... {% has_perm 'accounts.admin' request.user as is_admin %} {% if is_admin %} <li>システム管理者だけが見える</li> {% endif %}
{% load rules %}
でdjango-rulesを読み込み、{% has_perm 'accounts.admin' request.user as is_admin %}
でテンプレートタグ has_perm
を使っています。
has_perm
の引数はそれぞれ
- 第一引数
'accounts.admin'
- 権限を指定する
- ここでは管理者権限があるかを確認
- 第二引数
request.user
- ルール用の関数
is_admin(user)
に渡すUserオブジェクト
- ルール用の関数
です。
あとは、ルール用関数からTrue/Falseのいずれかが返ってくるので、 as
で変数に入れて判定を行います。
https://docs.djangoproject.com/en/3.0/howto/custom-template-tags/#django.template.Library.simple_tag
全体像はこちら
<!-- templates/myapp/news_list.html --> {% extends 'base.html' %} {% block load %} {% load rules %} {% endblock %} {% block title %}お知らせ一覧{% endblock %} {% block content %} <h3>お知らせ一覧</h3> <p>権限によって、表示/非表示を切り替える</p> <ul> {% has_perm 'accounts.admin' request.user as is_admin %} {% if is_admin %} <li>システム管理者だけが見える</li> {% endif %} {% has_perm 'accounts.manager' request.user as is_manager %} {% if is_manager %} <li>管理職以上が見える</li> {% endif %} {% has_perm 'accounts.employee' request.user as is_employee %} {% if is_employee %} <li>一般が見える</li> {% endif %} </ul> <table border="1" rules="all"> <thead> <tr> <th>News ID</th> <th>タイトル</th> </tr> </thead> <tbody> {% for news in object_list %} <tr> <td>{{ news.id }}</td> <td><a href="{% url 'myapp:detail' news.id %}">{{ news.title }}</a></td> </tr> {% endfor %} </tbody> </table> {% endblock %}
詳細画面
一覧画面と異なり、詳細画面の表示可否はViewで行います。
そのため、テンプレートでは権限による表示制御ロジックは入れていません。
<!-- templates/myapp/news_detail.html --> {% extends 'base.html' %} {% block title %}お知らせ詳細{% endblock %} {% block content %} <h3>お知らせ詳細</h3> <p>タイトル: {{ object.title }}</p> <p>内容: {{ object.content }}</p> <td><a href="{% url 'myapp:index' %}">一覧へ</a></td> {% endblock %}
403ページ
デフォルトの403ページだと、どのユーザーで403が表示されたのかがわからないため、今回はあえて403ページも自作します。
<!-- templates/403.html --> {% extends 'base.html' %} {% block title %}403エラー{% endblock %} {% block content %} <p>権限がありません</p> <td><a href="{% url 'myapp:index' %}">一覧へ</a></td> {% endblock %}
Viewの作成
一覧画面View
権限制御は行わないため、ふつうのListViewです。
class NewsListView(ListView): model = News
詳細画面View
詳細画面は権限の有無により、画面を表示/非表示を制御します。
そのため、
- DetailViewに
rules.contrib.views.PermissionRequiredMixin
を追加 - 属性として
permission_required
を追加し、権限を割り当てる
を行います*2。
https://github.com/dfunckt/django-rules#using-the-class-based-view-mixin
class NewsDetailView(PermissionRequiredMixin, DetailView): model = News permission_required = 'myapp.same_section'
今回は、一般職の場合だけ、ユーザーの所属部門がお知らせの部門と一致する場合のみお知らせ詳細を表示するため、 permission_required
に myapp.same_section
を設定しています。
settings.pyの修正
ここまでの動作をさせるために、settings.pyを修正します。
django-rules用としては、
- 権限を定義したファイル(*/rules.py)を探しやすくするよう、
INSTALLED_APPS
にrules.apps.AutodiscoverRulesConfig
を追加 - 認証バックエンド(
AUTHENTICATION_BACKENDS
)に django-rules のものを追加
です。
主な変更箇所はこちら。
INSTALLED_APPS = [ # ... # rules.pyを自動的に探してくれる書き方 'rules.apps.AutodiscoverRulesConfig', # 自作アプリ 'accounts.apps.AccountsConfig', 'myapp.apps.MyappConfig', ] MIDDLEWARE = [ ... # 末尾にログイン必須ミドルウェアを追加 'accounts.middlewares.LoginRequiredMiddleware', ] TEMPLATES = [ { # ... # テンプレートディレクトリを修正 'DIRS': [os.path.join(BASE_DIR, 'templates')], # ... }, ] # 差し替えたユーザーモデル AUTH_USER_MODEL = 'accounts.User' # ログインURL LOGIN_URL = 'accounts:login' # ログイン後のリダイレクトURL LOGIN_REDIRECT_URL = 'myapp:index' # django-rulesの設定 AUTHENTICATION_BACKENDS = ( 'rules.permissions.ObjectPermissionBackend', 'django.contrib.auth.backends.ModelBackend', )
主な実装箇所は以上です。
細かいところはソースコードを見てください。
動作確認
用意したデータ
fixtureとして用意しました。
部門は3種類です。この部門ごとに1つのお知らせも用意しました。
- システム部
- 製造部
- 営業部
ユーザーは以下の4種類を用意しました。
- システム管理者
- 管理職
- 製造部一般
- 営業部一般
なお、ユーザーもfixtureで作成しています。
ただ、fixtureだけではユーザーのパスワードを生成できません。
そのため、以下のsolution2を参考に、django shellで生成したパスワードをfixtureに入れてました。
How to add superuser in Django from fixture - Stack Overflow
./manage.py shell >>> from django.contrib.auth.hashers import make_password >>> make_password('test') 'pbkdf2_sha256$180000$7bi7okafLu5T$aDHSnnujzykcAbxzibg7MoynKozIF+D1VwS1Kx2mQcg='
一覧画面
未ログイン
未ログインでは一覧画面を見れないため、ログイン画面へ遷移します。
システム管理者
すべての項目が見えています。
役職者
システム管理者の項目が見えなくなりました。
製造部一般
一般だけの項目になりました。
詳細画面
製造部一覧向けのお知らせを、それぞれのユーザーで閲覧してみます。
未ログイン
一覧同様、ログイン画面へ遷移します。
システム管理者
閲覧できます。
役職者
閲覧できます。
製造部一般
閲覧できます。
営業部一般
権限がないため、403ページが表示されています。
ソースコード
GitHubに上げました。
https://github.com/thinkAmi-sandbox/django-rules-sample
*1:本来の権限的には一般権限ではタイトルも見えないようにすべきなんですが、今回はサンプルなのでそこまで細かく制御しません
*2:関数ベースビューの場合はデコレータを使います:https://github.com/dfunckt/django-rules#using-the-function-based-view-decorator