django-rulesを使って、オブジェクトレベルの認可判定をViewとテンプレートでそれぞれ実装してみた

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で実装してみます。

 

実装方針

今回は

  1. 職務により、全てのお知らせを閲覧可能 or 一部のお知らせのみ閲覧可能が切り替わること
  2. 一部の職務では、所属部門のお知らせのみ閲覧できること(ただし、タイトルは閲覧可能)

という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の idtitle だけ表示します。

この画面は一般権限でも閲覧可能としています*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_requiredmyapp.same_section を設定しています。

 

settings.pyの修正

ここまでの動作をさせるために、settings.pyを修正します。

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='

 

一覧画面
未ログイン

未ログインでは一覧画面を見れないため、ログイン画面へ遷移します。

f:id:thinkAmi:20200425084844p:plain

 

システム管理者

すべての項目が見えています。

f:id:thinkAmi:20200425084919p:plain

 

役職者

システム管理者の項目が見えなくなりました。

f:id:thinkAmi:20200425084937p:plain

 

製造部一般

一般だけの項目になりました。

f:id:thinkAmi:20200425084953p:plain

 

詳細画面

製造部一覧向けのお知らせを、それぞれのユーザーで閲覧してみます。

未ログイン

一覧同様、ログイン画面へ遷移します。

f:id:thinkAmi:20200425085006p:plain

 

システム管理者

閲覧できます。

f:id:thinkAmi:20200425085019p:plain

 

役職者

閲覧できます。

f:id:thinkAmi:20200425085046p:plain

 

製造部一般

閲覧できます。

f:id:thinkAmi:20200425085103p:plain

 

営業部一般

権限がないため、403ページが表示されています。

f:id:thinkAmi:20200425085119p:plain

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi-sandbox/django-rules-sample

*1:本来の権限的には一般権限ではタイトルも見えないようにすべきなんですが、今回はサンプルなのでそこまで細かく制御しません

*2:関数ベースビューの場合はデコレータを使います:https://github.com/dfunckt/django-rules#using-the-function-based-view-decorator