Source code for bridgekeeper.rules

"""Rule library that forms the core of Bridgekeeper.

This module defines the :class:`Rule` base class, as well as a
number of built-in rules.
"""

from django.db.models import Manager, Q, QuerySet
from django.db.models.fields.reverse_related import ForeignObjectRel


class Sentinel:
    def __init__(self, name):
        self.repr = name

    def __repr__(self):
        return self.repr


#: A sentinel value that represents the universal set. See
#: :meth:`Rule.query` for usage.
UNIVERSAL = Sentinel("UNIVERSAL")

#: A sentinel value that represents the empty set. See
#: :meth:`Rule.query` for usage.
EMPTY = Sentinel("EMPTY")


[docs]class Rule: """Base class for rules. All rules are instances of this class, but not directly; use (or write!) a subclass instead, as this class will raise :class:`NotImplementedError` if you try to actually do anything with it. """
[docs] def filter(self, user, queryset): """Filter a queryset to instances that satisfy this rule. Given a queryset and a user, this method will return a filtered queryset that contains only instances from the original queryset for which the user satisfies this rule. :param queryset: The initial queryset to filter :type queryset: django.db.models.QuerySet :param user: The user to match against. :type user: django.contrib.auth.models.User :returns: A filtered queryset :rtype: django.db.models.QuerySet .. warning:: If you are subclassing this class, don't override this method; override :meth:`query` instead. """ q_obj = self.query(user) if q_obj is UNIVERSAL: return queryset if q_obj is EMPTY: return queryset.none() return queryset.filter(q_obj)
[docs] def is_possible_for(self, user): """Check if it is possible for a user to satisfy this rule. Returns ``True`` if it is possible for an instance to exist for which the given user satisfies this rule, ``False`` otherwise. For example, in a multi-tenanted app, you might have a rule that allows access to model instances if a user is a staff user, or if the instance's tenant matches the user's tenant. In that case, :meth:`check`, when called without an instance, would return ``True`` only for staff users (since only they can see *every* instance). This method would return ``True`` for all users, because every user could possibly see an instance (whether it's one that exists currently in the database, or a hypothetical one that might in the future). Cases where this method would return ``False`` include where a user doesn't have the right role or subscription plan to use a feature at all; this method is the single-permission equivalent of :ref:`has-module-perms`. """ return self.query(user) is not EMPTY
[docs] def query(self, user): """Generate a :class:`~django.db.models.Q` object. .. note:: This method is used internally by :meth:`filter`; subclasses will need to override it but you should never need to call it directly. Given a user, return a :class:`~django.db.models.Q` object which will filter a queryset down to only instances for which the given user satisfies this rule. Alternatively, return :data:`UNIVERSAL` if this user satisfies this rule for every possible object, or :data:`EMPTY` if this user cannot satisfy this rule for any possible object. (These two values are usually only returned in "blanket rules" which depend only on some property of the user, e.g. the built-in :any:`is_staff`, but these are usually best created with the :class:`blanket_rule` decorator.) :param user: The user to match against. :type user: django.contrib.auth.models.User :returns: A query that will filter a queryset to match this rule. :rtype: django.db.models.Q """ raise NotImplementedError()
[docs] def check(self, user, instance=None): """Check if a user satisfies this rule. Given a user, return a boolean indicating if that user satisfies this rule for a given instance, or if none is provided, every instance. """ raise NotImplementedError()
def __and__(self, other): return And(self, other) def __or__(self, other): return Or(self, other) def __not__(self): return Not(self) def __invert__(self): return Not(self)
class BinaryCompositeRule(Rule): def __init__(self, left, right): self.left = left self.right = right def __repr__(self): return "({!r} {} {!r})".format(self.left, self.sym, self.right) class And(BinaryCompositeRule): sym = "&" def query(self, user): # Effectively an intersection (∩) of the two sets represented by # left and right's Q expressions, but wth special-casing for the # EMPTY (∅) and UNIVERSAL (U) values. left = self.left.query(user) # Because A ∩ ∅ = ∅, if either side returns EMPTY the entire # expression is EMPTY. if left is EMPTY: # Short-circuit evaluation, since the result of the RHS # can't affect what we'll return here return EMPTY right = self.right.query(user) if right is EMPTY: return EMPTY # Because A ∩ U = A, if one side returns UNIVERSAL the entire # expression is whatever is on the other side. if left is UNIVERSAL: return right if right is UNIVERSAL: return left return left & right def check(self, user, instance=None): return self.left.check(user, instance) and self.right.check(user, instance) class Or(BinaryCompositeRule): sym = "|" def query(self, user): # Effectively a union (∪) of the two sets represented by left # and right's Q expressions, but wth special-casing for the # EMPTY (∅) and UNIVERSAL (U) values. left = self.left.query(user) # Because A ∪ U = U, if either side returns UNIVERSAL the entire # expression is UNIVERSAL. if left is UNIVERSAL: # Short-circuit evaluation, since the result of the RHS # can't affect what we'll return here return UNIVERSAL right = self.right.query(user) if right is UNIVERSAL: return UNIVERSAL # Because A ∪ ∅ = A, if one side returns EMPTY the entire # expression is whatever is on the other side. if left is EMPTY: return right if right is EMPTY: return left return left | right def check(self, user, instance=None): return self.left.check(user, instance) or self.right.check(user, instance) class Not(Rule): def __init__(self, base): self.base = base def __repr__(self): return "~{!r}".format(self.base) def query(self, user): base = self.base.query(user) # Special case EMPTY and UNIVERSAL by inverting them. if base is EMPTY: return UNIVERSAL if base is UNIVERSAL: return EMPTY return ~base def check(self, user, instance=None): return not self.base.check(user, instance) def __invert__(self): return self.base class blanket_rule(Rule): # noqa: used as a decorator, so should be lowercase """A decorator for rules that don't depend on objects. This decorator provides a shorthand method for defining rules that depend only on the user. Decorate a function that takes a :class:`~django.contrib.auth.models.User` object and returns a boolean, and it will be turned into a :class:`Rule` instance, for example:: @blanket_rule def is_anonymous(user): return user.is_anonymous """ def __init__(self, rule_func, repr_string=None): self.rule_func = rule_func self.repr = repr_string or rule_func.__name__ def __repr__(self): return self.repr def query(self, user): return UNIVERSAL if self.rule_func(user) else EMPTY def check(self, user, instance=None): return self.rule_func(user)
[docs]@blanket_rule def always_allow(_): return True
[docs]@blanket_rule def always_deny(_): return False
[docs]@blanket_rule def is_authenticated(user): return user.is_authenticated
[docs]@blanket_rule def is_superuser(user): return user.is_superuser
[docs]@blanket_rule def is_staff(user): return user.is_staff
[docs]@blanket_rule def is_active(user): return user.is_active
[docs]class R(Rule): """Rules that allow access to some objects but not others. ``R`` takes a set of field lookups as keyword arguments. Each argument is a model attribute. Foreign keys can be traversed using double underscores, as in :class:`~django.db.models.Q` objects. The value assigned to each argument can be: - A value to match. - A function that accepts a user, and returns a value to match. - If the argument refers to a foreign key or many-to-many relationship, another :class:`~bridgekeeper.rules.Rule` object. """ def __init__(self, **kwargs): self.kwargs = kwargs def __repr__(self): return "R({})".format( ", ".join("{}={!r}".format(k, v) for k, v in self.kwargs.items()) ) def check(self, user, instance=None): if instance is None: return False # This loop exits early, returning False, if any argument # doesn't match. for key, value in self.kwargs.items(): # Find the appropriate LHS on this object, traversing # foreign keys if necessary. lhs = instance for key_fragment in key.split("__"): field = lhs.__class__._meta.get_field(key_fragment,) if isinstance(field, ForeignObjectRel): attr = field.get_accessor_name() else: attr = key_fragment lhs = getattr(lhs, attr) # Compare it against the RHS. # Note that the LHS will usually be a value, but in the case # of a ManyToMany or the 'other side' of a ForeignKey it # will be a RelatedManager. In this case, we need to check # if there is at least one model that matches the RHS. if isinstance(value, Rule): if isinstance(lhs, Manager): if not value.filter(user, lhs.all()).exists(): return False else: if not value.check(user, lhs): return False else: resolved_value = value(user) if callable(value) else value if isinstance(lhs, Manager): if resolved_value not in lhs.all(): return False else: if lhs != resolved_value: return False # Woohoo, everything matches! return True def query(self, user): accumulated_q = Q() for key, value in self.kwargs.items(): # TODO: check lookups are not being used if isinstance(value, Rule): child_q_obj = value.query(user) accumulated_q &= add_prefix(child_q_obj, key) else: resolved_value = value(user) if callable(value) else value accumulated_q &= Q(**{key: resolved_value}) return accumulated_q
[docs]class Attribute(Rule): """Rule class that checks the value of an instance attribute. .. deprecated:: 0.9 Use :class:`~bridgekeeper.rules.R` objects instead. :: # old Attribute('colour', matches='blue') Attribute('tenant', lambda user: user.tenant) # new R(colour='blue') R(tenant=lambda user: user.tenant) This rule is satisfied by model instances where the attribute given in ``attr`` matches the value given in ``matches``. :param attr: An attribute name to match against on the model instance. :type attr: str :param value: The value to match against, or a callable that takes a user and returns a value to match against. For instance, if you had a model class ``Widget`` with an attribute ``colour`` that was either ``'red'``, ``'green'`` or ``'blue'``, you could limit access to blue widgets with the following:: blue_widgets_only = Attribute('colour', matches='blue') Restricting access in a multi-tenanted application by matching a model's ``tenant`` to the user's might look like this:: applications_by_tenant = Attribute('tenant', lambda user: user.tenant) .. warning:: This rule uses Python equality (``==``) when checking a retrieved Python object, but performs an equality check on the database when filtering a QuerySet. Avoid using it with imprecise types (e.g. floats), and ensure that you are using the correct Python type (e.g. :class:`decimal.Decimal` for decimals rather than floats or strings), to prevent inconsistencies. """ def __init__(self, attr, matches): self.attr = attr self.matches = matches def get_match(self, user): return self.matches(user) if callable(self.matches) else self.matches def __repr__(self): return "Attribute({!r}, matches={!r})".format(self.attr, self.matches) def query(self, user): return Q(**{self.attr: self.get_match(user)}) def check(self, user, instance=None): if instance is None: return False return getattr(instance, self.attr) == self.get_match(user)
[docs]class Is(Rule): """Rule class that checks the identity of the instance. This rule is satisfied only by a the provided model instance. :param instance: The instance to match against, or a callable that takes a user and returns a value to match against. For instance, if you only wanted a user to be able to update their own profile:: own_profile = Is(lambda user: user.profile) """ def __init__(self, instance): self.instance = instance def get_instance(self, user): return self.instance(user) if callable(self.instance) else self.instance def __repr__(self): return "Is({!r})".format(self.instance) def query(self, user): return Q(pk=self.get_instance(user).pk) def check(self, user, instance=None): if instance is None: return False return instance == self.get_instance(user)
#: This rule is satisfied by the user object itself. current_user = Is(lambda user: user)
[docs]class In(Rule): """Rule class that checks the instance is a member of a collection. This rule is satisfied only by model instances that are members of the provided collection. :param collection: The collection to match against, or a callable that takes a user and returns a value to match against. For instance, if you only wanted to match groups a user is in:: own_profile = Is(lambda user: user.profile) """ def __init__(self, collection): self.collection = collection def get_collection(self, user): return self.collection(user) if callable(self.collection) else self.collection def __repr__(self): return "In({!r})".format(self.collection) def query(self, user): collection = self.get_collection(user) if isinstance(collection, QuerySet): return Q(pk__in=collection.values_list("pk")) return Q(pk__in=[x.pk for x in collection]) def check(self, user, instance=None): if instance is None: return False collection = self.get_collection(user) if isinstance(collection, QuerySet): # If we have a queryset, the rule passes if the instance is # of the same model, and the pk is present in the qs if not isinstance(instance, collection.model): return False return collection.filter(pk=instance.pk).exists() return instance in collection
#: This rule is satisfied by any group the user is in. in_current_groups = In(lambda user: user.groups.all()) # from https://groups.google.com/forum/#!msg/django-developers/jEkCdzGnzRE/6lJQV4AqCwAJ def add_prefix(q_obj, prefix): return Q( *( add_prefix(child, prefix) if isinstance(child, Q) else (prefix + "__" + child[0], child[1]) for child in q_obj.children ), _connector=q_obj.connector, _negated=q_obj.negated, )
[docs]class Relation(Rule): """Check that a rule applies to a ForeignKey. .. deprecated:: 0.9 Use :class:`~bridgekeeper.rules.R` objects instead. :: # old Relation('applicant', perms['foo.view_applicant']) # new R(applicant=perms['foo.view_applicant']) :param attr: Name of a foreign key attribute to check. :type attr: str :param rule: Rule to check the foreign key against. :type rule: Rule For example, given ``Applicant`` and ``Application`` models, to allow access to all applications to anyone who has permission to access the related applicant:: perms['foo.view_application'] = Relation( 'applicant', perms['foo.view_applicant']) """ def __init__(self, attr, rule): self.attr = attr self.rule = rule def __repr__(self): return "Relation({!r}, {!r})".format(self.attr, self.rule) def query(self, user): related_q = self.rule.query(user) if related_q is UNIVERSAL or related_q is EMPTY: return related_q return add_prefix(related_q, self.attr) def check(self, user, instance=None): if instance is None: return self.rule.check(user, None) return self.rule.check(user, getattr(instance, self.attr))
[docs]class ManyRelation(Rule): """Check that a rule applies to a many-object relationship. .. deprecated:: 0.9 Use :class:`~bridgekeeper.rules.R` objects instead. :: # old ManyRelation('agency', Is(lambda user: user.agency)) # new R(agency=lambda user: user.agency) This can be used in a similar fashion to :class:`Relation`, but across a :class:`~django.db.models.ManyToManyField`, or the remote end of a :class:`~django.db.models.ForeignKey`. :param query_attr: Name of a many-object relationship to check. This This is the name that you use when filtering this relationship using ``.filter()``. If you are on the side of the relationship where the field is defined, this is typically the lowercased model name (e.g. ``mymodel`` on its own, *not* ``mymodel_set``), unless you've set :attr:`~django.db.models.ForeignKey.related_name` or :attr:`~django.db.models.ForeignKey.related_query_name`. :type query_attr: str :param rule: Rule to check the foreign object against. :type rule: Rule For example, given ``Agency`` and ``Customer`` models, to allow agency users access only to customers that have a relationship with their agency:: perms['foo.view_customer'] = ManyRelation( 'agency', Is(lambda user: user.agency)) """ def __init__(self, query_attr, rule): # TODO: add support for 'all' as well as 'exists' self.query_attr = query_attr self.rule = rule def __repr__(self): return "ManyRelation({!r}, {!r})".format(self.query_attr, self.rule) def query(self, user): # Unfortunately you can't use Q objects on a relation, only proper # QuerySets. related_q = self.rule.query(user) if related_q is UNIVERSAL or related_q is EMPTY: return related_q return add_prefix(related_q, self.query_attr) def check(self, user, instance=None): if instance is None: return self.rule.check(user, None) related_q = self.rule.query(user) if related_q is UNIVERSAL or related_q is EMPTY: return related_q attr = instance.__class__._meta.get_field(self.query_attr,).get_accessor_name() related_manager = getattr(instance, attr) return related_manager.filter(related_q).exists()