Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace most rule classes with R objects #12

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .github/main.workflow

This file was deleted.

20 changes: 20 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Documentation
on: [push]
jobs:
build-docs:
name: Build documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- uses: docker://thekevjames/nox
with:
entrypoint: nox
args: -s docs
- uses: actions/gcloud/auth@master
env:
GCLOUD_AUTH: ${{ secrets.GCLOUD_AUTH }}
- uses: vsoch/gcloud-actions/gsutil@07cfbb050596be267fa37be76c76b7765d82a207
with:
args: -m rsync -rd docs/_build/html gs://bridgekeeper-docs/${{ github.ref }}
13 changes: 13 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: Tests
on: [push]
jobs:
test:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- uses: docker://thekevjames/nox
with:
entrypoint: nox
128 changes: 128 additions & 0 deletions bridgekeeper/rule_r_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import pytest
from shrubberies.factories import ShrubberyFactory, StoreFactory, UserFactory
from shrubberies.models import Shrubbery, Store

from .rules import R

pytestmark = pytest.mark.django_db


def test_constant_attribute():
user = UserFactory()
s1 = StoreFactory(name='a')
s2 = StoreFactory(name='b')
pr = R(name='a')

assert pr.check(user, s1)
assert not pr.check(user, s2)

filtered_qs_r = pr.filter(user, Store.objects.all())
assert filtered_qs_r.count() == 1
assert s1 in filtered_qs_r
assert s2 not in filtered_qs_r


def test_user_func_attribute():
u1 = UserFactory(username='a')
u2 = UserFactory(username='b')
s1 = StoreFactory(name='a')
s2 = StoreFactory(name='b')
pr = R(name=lambda u: u.username)

assert pr.check(u1, s1)
assert pr.check(u2, s2)
assert not pr.check(u1, s2)
assert not pr.check(u2, s1)

qs1_r = pr.filter(u1, Store.objects.all())
qs2_r = pr.filter(u2, Store.objects.all())
assert qs1_r.count() == 1
assert s1 in qs1_r
assert s2 not in qs1_r
assert qs2_r.count() == 1
assert s2 in qs2_r
assert s1 not in qs2_r


def test_when_called_without_object():
user = UserFactory(username='a')
pr = R(name=lambda u: u.username)
assert not pr.check(user)


def test_relation_to_user():
u1 = UserFactory()
u2 = UserFactory()
s1 = ShrubberyFactory(branch=u1.profile.branch)
s2 = ShrubberyFactory(branch=u2.profile.branch)
belongs_to_branch_r = R(branch=lambda u: u.profile.branch)

assert belongs_to_branch_r.check(u1, s1)
assert belongs_to_branch_r.check(u2, s2)
assert not belongs_to_branch_r.check(u1, s2)
assert not belongs_to_branch_r.check(u2, s1)

qs1_r = belongs_to_branch_r.filter(u1, Shrubbery.objects.all())
qs2_r = belongs_to_branch_r.filter(u2, Shrubbery.objects.all())
assert qs1_r.count() == 1
assert s1 in qs1_r
assert s2 not in qs1_r
assert qs2_r.count() == 1
assert s2 in qs2_r
assert s1 not in qs2_r


def test_relation_never_global():
user = UserFactory()
belongs_to_branch_r = R(branch=lambda u: u.profile.branch)
assert not belongs_to_branch_r.check(user)


def test_traverse_fk_user_func():
user = UserFactory()
shrubbery_match = ShrubberyFactory(branch__store=user.profile.branch.store)
shrubbery_nomatch = ShrubberyFactory()
store_check_r = R(branch__store=lambda user: user.profile.branch.store)

assert store_check_r.check(user, shrubbery_match)
assert not store_check_r.check(user, shrubbery_nomatch)

qs = store_check_r.filter(user, Shrubbery.objects.all())
assert shrubbery_match in qs
assert shrubbery_nomatch not in qs


def test_nested_rule_object():
user = UserFactory()
shrubbery_match = ShrubberyFactory(branch__store=user.profile.branch.store)
shrubbery_nomatch = ShrubberyFactory()
store_check_r = R(branch=R(store=lambda user: user.profile.branch.store))

assert store_check_r.check(user, shrubbery_match)
assert not store_check_r.check(user, shrubbery_nomatch)

qs = store_check_r.filter(user, Shrubbery.objects.all())
assert shrubbery_match in qs
assert shrubbery_nomatch not in qs


def test_many_relation_to_user():
s1 = StoreFactory()
s2 = StoreFactory()
u1 = UserFactory(profile__branch__store=s1)
u2 = UserFactory(profile__branch__store=s2)
user_branch_in_store = R(branch=lambda u: u.profile.branch)

assert user_branch_in_store.check(u1, s1)
assert user_branch_in_store.check(u2, s2)
assert not user_branch_in_store.check(u1, s2)
assert not user_branch_in_store.check(u2, s1)

qs1 = user_branch_in_store.filter(u1, Store.objects.all())
qs2 = user_branch_in_store.filter(u2, Store.objects.all())
assert qs1.count() == 1
assert s1 in qs1
assert s2 not in qs1
assert qs2.count() == 1
assert s2 in qs2
assert s1 not in qs2
128 changes: 127 additions & 1 deletion bridgekeeper/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
number of built-in rules.
"""

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


class Sentinel:
Expand Down Expand Up @@ -279,9 +280,110 @@ def is_active(user):
return user.is_active


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


class Attribute(Rule):
"""Rule class that checks the value of an instance attribute.

.. deprecated:: 0.8

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``.

Expand Down Expand Up @@ -435,6 +537,18 @@ def add_prefix(q_obj, prefix):
class Relation(Rule):
"""Check that a rule applies to a ForeignKey.

.. deprecated:: 0.8

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.
Expand Down Expand Up @@ -471,6 +585,18 @@ def check(self, user, instance=None):
class ManyRelation(Rule):
"""Check that a rule applies to a many-object relationship.

.. deprecated:: 0.8

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`.
Expand Down
12 changes: 9 additions & 3 deletions docs/api/rules.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,21 @@ Rules
Rule Classes
------------

.. autoclass:: R

.. autoclass:: Is

.. autoclass:: In

Deprecated rule classes
-----------------------

.. autoclass:: Attribute

.. autoclass:: Relation

.. autoclass:: ManyRelation

.. autoclass:: Is

.. autoclass:: In

Built-in rule instances
-----------------------
Expand Down
4 changes: 2 additions & 2 deletions docs/tutorial/installation.rst
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Installing Bridgekeeper
=======================

First, install the ``bridgekeeper`` package from PyPI.
First, install the ``bridgekeeper`` package from PyPI, using one of the following, depending on which tool you're using:

.. code-block:: sh

$ pip install bridgekeeper
# or, if you're using pipenv
$ poetry add bridgekeeper
$ pipenv install bridgekeeper

Then, add Bridgekeeper to your ``settings.py``:
Expand Down
Loading