9

を使用してオブジェクトごとのアクセス許可を作成できるようにしたいと考えていますdjango-guardian

しかし、これらのアクセス許可を取り巻くロジックのレイヤーを追加したいと思います。たとえば、誰かがedit_bookのパーミッションを持っている場合、その本Bookを編集するパーミッションはPages暗黙的でなければなりません。rulesパッケージは理想的なようです。

4

1 に答える 1

19

tl;drはい、可能です。 のスケーラビリティの問題のいくつかに対処できますがRules、オブジェクトごとにクエリを実行することはできないため、アクセス許可でフィルター処理されたクエリは高価です。Q複合的ではあるがより複雑なソリューションが提案されており、実行時に lazyのようなオブジェクトを使用して遅延ルール セットを SQL にコンパイルすることでこれを回避します。

以下が機能するようです:

import rules
import guardian

@rules.predicate
def is_page_book_editor(user, page):
    return user.has_perm('books.edit_book', page.book)

@rules.predicate
def is_page_editor(user, page):
    return user.has_perm('pages.edit_page', page)

rules.add_perm('pages.can_edit_page', is_page_book_editor | is_page_editor)

次に確認します。

joe.has_perm('pages.can_edit_page', page34)

または:

@permission_required('pages.can_edit_page', fn=objectgetter(Page, 'page_id'))
def post_update(request, page_id):
    # ...

認証バックエンドが定義されている場合:

AUTHENTICATION_BACKENDS = (
    'rules.permissions.ObjectPermissionBackend',
    'django.contrib.auth.backends.ModelBackend',
    'guardian.backends.ObjectPermissionBackend',
)

インポート:

from django.contrib.auth.models import User
import rules
import guardian
from guardian.shortcuts import assign_perm
from myapp.models import Book, Page

テスト:

joe = User.objects.create(username='joe', email='joe@example.com')
page23 = Page.objects.filter(id=123)
assign_perm('edit_page', joe, page23)
joe.has_perm('edit_page', page23)
is_page_editor(joe, page23)  # returns True
joe.has_perm('can_edit_page', i)  # returns True

rules.remove_perm('can_edit_page')
rules.add_perm('can_edit_page', is_page_book_editor & is_page_editor)
joe.has_perm('can_edit_page', i)  # returns False

これに関する問題は、ルールがチェックされるたびに、各述語がデータベースを呼び出すことです。以下はキャッシュを追加して、ルール チェックごとにクエリが 1 つだけになるようにします。

@rules.predicate
def is_page_book_viewer(user, instance):
    if is_page_book_viewer.context.get('user_perms') is None:
        is_page_book_viewer.context['user_perms'] = guardian.shortcuts.get_perms(user, page.book)
    return 'view_book' in is_page_book_viewer.context.get('user_perms')

@rules.predicate(bind=True)
def is_page_viewer(self, user, instance):
    if self.context.get('user_perms') is None:
        self.context['user_perms'] = guardian.shortcuts.get_perms(user, instance)
    return 'view_page' in self.context.get('user_perms')

(2 番目の例ではバインドして を使用selfしていますが、これは述語名を使用するのと同じです。)


複雑な複合パーミッションを実行しているため、django-guardian の一般的な外部キーを、次のようにデータベースによって最適化およびインデックス化できる実際の外部キーに置き換えるのがおそらく賢明です。

class PageUserObjectPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(Page)

class PageGroupObjectPermission(GroupObjectPermissionBase):
    content_object = models.ForeignKey(Page)

class BookUserObjectPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(Book)

class BookGroupObjectPermission(GroupObjectPermissionBase):
    content_object = models.ForeignKey(Book)

バグがあります。アクセス許可をPage同じBook場所にキャッシュしています。これらを区別して個別にキャッシュする必要があります。また、繰り返されるコードを独自のメソッドにカプセル化しましょう。最後に、get()デフォルトを指定して、ユーザーがNone.

def cache_permissions(predicate, user, instance):
    """
    Cache all permissions this user has on this instance, for potential reuse by other predicates in this rule check.
    """
    key = 'user_%s_perms_%s_%s' % (user.pk, type(instance).__name__, instance.pk)
    if predicate.context.get(key, -1) == -1:
        predicate.context[key] = guardian.shortcuts.get_perms(user, instance)
    return predicate.context[key]

このようにして、オブジェクトのアクセス許可が個別にキャッシュされます。(どのルールも 1 人のユーザーのみをチェックするため、ユーザー ID を含めるkey必要はありませんが、もう少し将来性があります。)

次に、述語を次のように定義できます。

@rules.predicate(bind=True)
def is_page_book_viewer(self, user, instance: Page):
    return 'view_book' in cache_permissions(self, user, instance.book)

の制限の 1 つrulesは、パーミッション チェックをユーザーに基づいて個別に実行する必要があることですが、多くの場合、ユーザーが特定のパーミッションを持っているすべてのオブジェクトを取得する必要があります。たとえば、ユーザーが編集権限を持っているすべてのページのリストを取得するには、許可されたすべてのオブジェクトを 1 回のクエリで返すの[p for p in Pages.objects.all() if usr.has_perm('can_edit_page', p)]ではなく、を繰り返し呼び出す必要があります。usr.has_perm('can_edit_page')

この制限に完全に対処することはできませんが、リスト内のすべてのオブジェクトをチェックする必要がない場合はnext、レイジー ジェネレーター コルーチン ベースのクエリセットを使用してクエリの数を減らすことができます。上記の例では、リストの最後に移動できない場合や、リスト内のオブジェクトに権限があるか(...)どうかのみを確認する必要がある場合に使用できます。または、以下のように、通常のループ コードと同等になります。[...]next(...)breakreturn

モデルに自己結合階層がある状況があり、モデルの子孫のいずれかにアクセス許可があるかどうかを知る必要があるだけです。コードは、連続するノードの子孫を使用してテーブルを再帰的にクエリする必要があります。しかし、パーミッションを持つオブジェクトが見つかったら、それ以上クエリを実行する必要はありません。私はこれを次のようにしました。(誰かがオブジェクトに対するアクセス許可を持っているかどうかに興味があり、非汎用キーを指定したことに注意してください。特定のユーザーのアクセス許可を確認している場合はuser.has_perm('perm_name', obj)、ルールを使用するために使用できます。)

class Foo(models.Model):
    parent = models.ForeignKey('Foo', blank=True, null=True)

    def descendants(self):
        """
        When callers don't need the complete list (eg, checking if any dependent is 
        viewable by any user), we run fewer queries by only going into the dependent 
        hierarchy as much as necessary.
        """
        immediate_descendants = Foo.objects.filter(parent=self)
        for x in immediate_descendants:
            yield x
        for x in immediate_descendants:
            for y in x.descendants():
                yield y

    def obj_or_descendant_has_perm(self, perm_code):
        perm_id = Permission.objects.get(codename=perm_code).id

        if FooUserObjectPermission.objects.filter(permission_id=perm_id,
                                                  content_object=self).exists()
            return True
        if FooGroupObjectPermission.objects.filter(permission_id=perm_id,
                                                   content_object=self).exists()
            return True

        for o in self.descendants():
            if FooUserObjectPermission.objects.filter(permission_id=perm_id,
                                                      content_object=self).exists()
                return True
            if FooGroupObjectPermission.objects.filter(permission_id=perm_id,
                                                       content_object=self).exists()
                return True

        return False

この単純な自己結合がある場合は、treebeard階層をモデル化するより効率的な方法 (具体化されたパス、ネストされたセット、または隣接リスト) を確認してください。私の場合、自己結合は他のテーブルを介していたため、これは不可能でした。

さらに一歩進んで、子孫からクエリセットを返すことでグループの選択を許可しました。

class Foo(models.Model):
    parent = models.ForeignKey('Foo', blank=True, null=True)

    def descendants(self):
        """
        When callers don't need the complete list (eg, checking if any dependent is 
        viewable by any user), we run fewer queries by only going into the dependent 
        hierarchy as much as necessary. Returns a generator of querysets of Foo objects.
        """
        immediate_descendants = Foo.objects.filter(parent=self)
        yield immediate_descendants
        for x in immediate_descendants:
            for y in x.descendants():
                yield y

    def obj_or_descendant_has_perm(self, perm_code):
        perm_id = Permission.objects.get(codename=perm_code).id

        if FooUserObjectPermission.objects.filter(permission_id=perm_id,
                                                  content_object=self).exists()
            return True
        if FooGroupObjectPermission.objects.filter(permission_id=perm_id,
                                                   content_object=self).exists()
            return True

        for gen in self.descendants():
            if FooUserObjectPermission.objects.filter(permission_id=perm_id,
                                                      content_object__in=gen).exists()
                return True
            if FooGroupObjectPermission.objects.filter(permission_id=perm_id,
                                                       content_object__in=gen).exists()
                return True

        return False

ただし、残念ながら、クエリ内のすべてのオブジェクトではなく、オブジェクトごとにのみプリフェッチしてキャッシュできます。これは、各オブジェクトがチェックされるたびdjango-guardianにレイヤー チェックの SQL サブクエリが生成され、プリキャッシュできないためです。Rulesまた、両方のパーミッション バックエンドを Django に登録すると、両方が毎回チェックされることになり、名前が重複していると予期しない承認が発生する可能性があります。

だから私はのようなものに行きましたがRules、それは実行時にクエリセットフィルターに遅延コンパイルされます。これは、Guardian パーミッション、Django Q オブジェクト、および遅延評価された Q オブジェクトのルールへのブール値の組み合わせを示すために考案された例です。

add_rule('kimsim_app.model_run.view',
    LazyGuardianPermission('kimsim_app.view_model') &
    (
        LazyGuardianPermission('kimsim_app.saved_model') |
        LazyGuardianPermission('kimsim_app.saved_model')
    )
    & ~LazyQ('modelgroupobjectpermission__group__user', 'request.user')
    & ~Q(number_of_failures__lte=42)
)
lazy = LazyPermission('kimsim_app.model_run.view') & ~LazyGuardianPermission('kimsim_app.view_model')

次に、ユーザーがアクセスできるすべての「モデル」を取得するには:

Model.objects.filter(lazy.convert_to_q(user=u))

ユーザーがモデルへのアクセス権を持っているかどうかを確認するにはm(アクセス権がある場合は返しm、そうでないNone場合は返します):

Model.objects.filter(lazy.convert_to_q(user=u, obj=m))

これは、DRF、Django 管理者、テンプレート タグなどのパーミッション バックエンドに組み込むことができます。コードは次のとおりです。

class BaseLazyQ(Q):
    """
    Is the type instantiated by the Q library when it parses the operators linking Q, LazyQ, LazyPermission and other
    BaseLazyQ subclasses, and generates the 'lazy' Q node tree for a rule.
    """
    def __init__(self, *args, **kwargs):
        # If no args, this is a connector node joining two sub-clauses
        # Or if args[0] is not a string, this is a standard, fully declared Q object
        super(BaseLazyQ, self).__init__(*args, **kwargs)
        logging.info('Instantiated fully declared BaseLazyQ %s, children %s', self.connector, self.children)

    # These two overrides force the connector nodes to be of type `BaseLazyQ`, ie, `LazyQ() & LazyGuardian()`
    # creates a BaseLazyQ connector node. These are then simply cloned on calling `convert_to_q()`.
    # Sub-classes do different conversion processing in `convert_to_q`.

    # They also disable `squash`, as child Q objects have not been instantiated yet: 'lazy' Q or
    # guardian permissions are still awaiting values request & obj.

    def _combine(self, other, conn):
        if not isinstance(other, Q):
            raise TypeError(other)
        obj = BaseLazyQ()
        obj.connector = conn
        obj.add(self, conn, squash=False)
        obj.add(other, conn, squash=False)
        return obj

    def __invert__(self):
        obj = BaseLazyQ()
        obj.add(self, self.AND, squash=False)
        obj.negate()
        return obj

    def convert_to_q(self, *args, **kwargs):
        """
        Generates a tree of fully specified Q() objects at run time from our tree of lazy Q, Guardian and LazyPermission
        objects, by passing them the ``request`` and ``obj`` objects of the current request.

        Note that only kwargs ``request`` or ``obj`` can be used if you will integrate with Django ModelAdmin and
        django-restframework permissions classes.

        :param request: From the current request
        :param obj: Optional - the object permissions are being tested for, if this is object-specific.
        :return: A tree of Q() objects that can be applied to a queryset of type ``obj``
        """
        logging.info('Converting fully declared BaseLazyQ conn %s children %s', self.connector, self.children)
        q = self.__class__._new_instance(children=[], connector=self.connector, negated=self.negated)
        for predicate in self.children:
            if isinstance(predicate, BaseLazyQ):
                # Including subclasses
                q.children.append(predicate.convert_to_q(*args, **kwargs))
            else:
                # Q or Node
                q.children.append(predicate.clone())
            logging.info('Cloning child Q %s', predicate)
        return q


class AlwaysQ(BaseLazyQ):
    """
    This class is used for permissions that are always granted or denied regardless of user, request, object, etc.
    """
    def __init__(self, always_allow, *args, **kwargs):
        """
        Initializes a class which always permits or denies a particular permission. Still subject to boolean operators,
        ie, `AlwaysQ('allow') & [some failing test/s]` will refuse permission.
        Likewise `AlwaysQ('deny') | [some passing test/s]` will grant permission.
        :param always_allow: Must be set to `'allow'` to always allow, or `'deny'` to always deny.
        """
        super(AlwaysQ, self).__init__(*args, **kwargs)
        if not always_allow in ['allow', 'deny']:
            raise LazyPermDeclarationError('AlwaysQ must be declared as either \'allow\' or \'deny\'.')
        self.always_allow = always_allow

    def convert_to_q(self, *args, **kwargs):
        return Q(pk__isnull=not self.always_allow)


class LazyQ(BaseLazyQ):

    def __init__(self, *args, **kwargs):
        super(LazyQ, self).__init__(*args, **kwargs)
        if args and len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], str):
            logging.info('Instantiating LazyQ %s %s', args[0], args[1])
            self.field = args[0]
            attrs = args[1].split('.')
            self.parameter = attrs[0]
            self.attributes = attrs[1:]
        else:
            raise LazyPermDeclarationError('LazyQ must be declared with a Q query string and the naming of the '
                                        'parameter attributes to assign it.')

    def convert_to_q(self, *args, **kwargs):
        """
        Generates a tree of fully specified Q() objects at run time, from our tree of lazy LazyPermission() and LazyQ()
        objects, by passing them the ``request`` and ``obj`` objects of the current request.

        Note that only kwargs ``request`` or ``obj`` can be used if we are to integrate with Django ModelAdmin and
        django-restframework permissions classes.

        :param request: From the current request
        :param obj: Optional - the object permissions are being tested for, if this is object-specific.
        :return: A tree of Q() objects that can be applied to a queryset of type ``obj``
        """
        logging.info('Converting LazyQ conn %s children %s args %s kwargs %s', self.connector, self.children, args, kwargs)
        value = kwargs[self.parameter]
        for attr in self.attributes:
            value = getattr(value, attr)
            logging.info('attr %s = %s', attr, value)
        return Q((self.field, value))


class LazyGuardianPermission(BaseLazyQ):
    """
    This class supports lazy guardian permissions, whose request and obj are to be passed at runtime.
    """
    def __init__(self, permission, globals_override=False, use_groups=True, related_object=None, *args, **kwargs):
        """
        Instantiates a lazy guardian permission that can later be converted to fully defined Q objects when passed
        request and (optionally) obj at request time.

        :param permission: The fully qualified guardian permission name, including the app label, eg, app.action_model

        :param globals_override: If 'allow', if the user has the permission on the model, then they have the
        permission on every object. It is not possible to disable global permissions inherited through group ownership.

        If 'deny', the user must have *both* the global permission, and the permission on the object. Removing the
        global permission for a user effectively removes their permission on all that model's objects.

        It is not possible to disable global permissions inherited through group ownership, and so only use those
        allocated to a user, ie, use_groups has no effect and is always True for global permission checks, as they are
        provided by the Django auth ModelBackend.

        Default False, which means global permissions are ignored.

        :param use_groups: If False, permissions a user has by group membership will not be considered.
        Default True, which means this check will check the permissions of groups the user is in. Note that this does
        not affect the `allow_groups` option, or checks that are not object-specific. These will always include group
        permissions, as determined by the Django auth ModelBackend.

        :param related_object: If the guardian permission is on a model related to the current one, this is the
        query string path from the current model to that model.
        """
        logging.info('Instantiating LazyGuardianPermission %s', permission)
        super(LazyGuardianPermission, self).__init__(*args, **kwargs)
        if isinstance(permission, str):
            perm_elems = permission.split('.')
            if len(perm_elems) == 2:
                # This specifies a guardian permission
                self.app_label = perm_elems[0]
                try:
                    self.permission = Permission.objects.select_related('content_type')\
                                                        .get(content_type__app_label=perm_elems[0],
                                                            codename=perm_elems[1])
                except Permission.DoesNotExist:
                    raise LazyPermDeclarationError('Guardian permission %s not found. LazyGuardianPermission must be '
                                                'passed a fully qualified guardian permission, eg, '
                                                'app.action_model. Q, LazyQ or LazyPermission objects can also be '
                                                'used.' % permission)
                self.related_object = related_object
                self.use_groups = use_groups
                self.globals_override = globals_override
                self.model_cls = self.permission.content_type.model_class()
            else:
                raise LazyPermDeclarationError('Guardian permission %s not found. LazyGuardianPermission must contain '
                                            'a fully qualified guardian permission, eg, app_action_model. Q, LazyQ '
                                            'or LazyPermission objects can also be used.' % permission)
        else:
            raise LazyPermDeclarationError('LazyGuardianPermission must be declared with a fully qualified guardian '
                                        'permission name, eg, app.action_model. <%s> not a valid parameter.' %
                                        str(permission))

    def convert_to_q(self, user, obj=None):
        """
        Generates a tree of fully specified Q() objects at run time to test this Guardian permission, by passing them
        the `request` and `obj` objects of the current request.

        :param user: From the current request
        :param obj: Optional - the object permissions are being tested for, if this is object-specific.
        :return: A tree of Q() objects that can be applied to a queryset of type ``obj``
        """
        logging.info('Converting LazyGuardianPermission %s%s', '~' if self.negated else '', self.permission.codename)

        if self.globals_override:
            has_global = user.has_perm('%s.%s' % (self.app_label, self.permission.codename))
            if has_global and self.globals_override == 'allow':
                return Q(pk__isnull=False)
            elif not has_global and self.globals_override == 'deny':
                return Q(pk__isnull=True)

        related_object_prefix = '%s__' % self.related_object if self.related_object else ''

        user_obj_perms_model = get_user_obj_perms_model(self.model_cls)
        group_obj_perms_model = get_group_obj_perms_model(self.model_cls)

        # logging.info('%s %s %s', self.model_cls, user_obj_perms_model, user_obj_perms_model.objects)

        if user_obj_perms_model.objects.is_generic():
            raise LazyPermDeclarationError('%s appears to be using generic foreign keys. LazyPermissions '
                                        'does not support Guardian permissions maintained via generic '
                                        'foreign keys, and insists you specify a custom table joining '
                                        'object, permission and user, for example `class '
                                        'DatasetUserObjectPermission(UserObjectPermissionBase): '
                                        'content_object = models.ForeignKey(Dataset)` and likewise '
                                        'for Groups. This is also more performant and maintains '
                                        'referential integrity.' % self.permission)

        user_obj_perms_model_ref = '%s%s' % (related_object_prefix,
                                            user_obj_perms_model.content_object.field.related_query_name())

        if obj:
            filters = (
                Q(('%s__user' % user_obj_perms_model_ref, user)) &
                Q(('%s__permission' % user_obj_perms_model_ref, self.permission)) &
                Q(('%s__content_object' % user_obj_perms_model_ref, obj.pk))
            )
        else:
            filters = (
                Q(('%s__user' % user_obj_perms_model_ref, user)) &
                Q(('%s__permission' % user_obj_perms_model_ref, self.permission))
            )

        if self.use_groups:

            if user_obj_perms_model.objects.is_generic():
                raise LazyPermDeclarationError('%s appears to be using generic foreign keys. LazyPermissions '
                                            'does not support Guardian permissions maintained via generic '
                                            'foreign keys, and insists you specify a custom table joining '
                                            'object, permission and user, for example `class '
                                            'DatasetGroupObjectPermission(GroupObjectPermissionBase): '
                                            'content_object = models.ForeignKey(Dataset)` and likewise '
                                            'for Users. This is also more performant and maintains '
                                            'referential integrity.' % self.permission)

            group_obj_perms_model_ref = '%s%s' % (related_object_prefix,
                                                group_obj_perms_model.content_object.field.related_query_name())

            if obj:
                filters |= (
                    Q(('%s__group__user' % group_obj_perms_model_ref, user)) &
                    Q(('%s__permission' % group_obj_perms_model_ref, self.permission)) &
                    Q(('%s__content_object' % group_obj_perms_model_ref, obj.pk))
                )
            else:
                filters |= (
                    Q(('%s__group__user' % group_obj_perms_model_ref, user)) &
                    Q(('%s__permission' % group_obj_perms_model_ref, self.permission))
                )

        logging.info('Converted non-declared LazyGuardianPermission %s%s filters %s',
                    '~' if self.negated else '', self.permission.codename, filters)

        return Q(filters)


class LazyPermission(BaseLazyQ):
    """
    This class supports recursive LazyPermission references, converted to lazy q or guardian checks on
    declaration then treated identically on calling.
    """
    def __init__(self, permission=None, *args, **kwargs):
        logging.info('instantiating gorm permission=%s', permission)
        super(LazyPermission, self).__init__(*args, **kwargs)
        if isinstance(permission, str):
            try:
                # This is a recursive LazyPermission reference, so add it as a sub-tree
                self.children.append(default_rules[permission])
            except KeyError:
                raise LazyPermDeclarationError('%s not found in rule_set. LazyPermission must contain a fully '
                                            'qualified guardian permission, eg, app.action_model, or another '
                                            'LazyPermission\'s key.' % permission)

            logging.info('Instantiated LazyPermission %s as LazyGuardianPermission sub-tree %s.',
                        permission, self.children)
        else:
            raise LazyPermDeclarationError('LazyPermission must be declared with either a fully qualified guardian '
                                        'permission, eg, app.action_model, or another LazyPermission\' key.')


class RuleSet(dict):
    def test_rule(self, name, *args, **kwargs):
        return name in self and self[name].convert_to_q(*args, **kwargs)

    def rule_exists(self, name):
        return name in self

    def add_rule(self, name, pred):
        if name in self:
            raise KeyError('A rule with name `%s` already exists' % name)
        self[name] = pred

    def remove_rule(self, name):
        del self[name]
于 2016-05-18T18:48:53.817 に答える