Giter VIP home page Giter VIP logo

baya's Introduction

Baya

Baya is a library for using nested LDAP Groups for authorization. It lets you put URLs, methods, CBVs, and Admin views behind access control that uses nested LDAP groupOfNames.

This also includes an example Django site in the testsite directory.

The Baya Weaver is a species of bird that weaves complex nests.

Image courtesy J.M.Garg on Wikipedia. This file is licensed under the Creative Commons Attribution 3.0 Unported license.

Installation

pip install baya

If you wish to use mockldap during your development process with Baya, you can install it as part of the optional development dependencies by using:

pip install baya[development]
INSTALLED_APPS = (
    ...
    'baya',
    ...
)

# SessionMiddleware must be active

AUTHENTICATION_BACKENDS = (
    'baya.backend.NestedLDAPGroupsBackend',
    # If you're already using django_auth_ldap.backend.LDAPBackend, delete it
    'django.contrib.auth.backends.ModelBackend',
)

LDAP Prerequisites

Note that you must be using GroupOfNames or GroupOfUniqueNames and not PosixGroup. You should also emable the memberOf overlay (but with some effort we can eliminate that requirement).

Configuration

You need to set up your ldap settings. If you already have a working version of django_auth_ldap then you're nearly finished. Make sure the following settings are configured:

import ldap
from django_auth_ldap.config import LDAPSearch
from django_auth_ldap.config import NestedGroupOfNamesType

AUTH_LDAP_GROUP_TYPE = NestedGroupOfNamesType()
AUTH_LDAP_FIND_GROUP_PERMS = True
AUTH_LDAP_MIRROR_GROUPS = True

AUTH_LDAP_BIND_DN = 'cn=auth,dc=example,dc=com'
AUTH_LDAP_BIND_PASSWORD = 'password'
AUTH_LDAP_SERVER_URI = 'ldaps://ldap'

AUTH_LDAP_USER_SEARCH = LDAPSearch(
    'ou=People,dc=example,dc=com',
    ldap.SCOPE_SUBTREE,
    '(uid=%(user)s)')

AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
    'ou=Access,dc=example,dc=com',
    ldap.SCOPE_SUBTREE,
    "(objectClass=groupOfNames)"
)

# Change this to True when testing locally to disable permissions checking
BAYA_ALLOW_ALL = False

# If you have a custom, internal-only login url, you can set this:
# (If you don't set this, baya defaults to LOGIN_URL)
BAYA_LOGIN_URL = "/internal/login/url"

Of course, change the values to match your actual setup.

LDAP client configuration

By default, Baya uses ldap.initialize to make the LDAP connection. To use the ReconnectLDAPObject provided by the ldap package, configure this setting:

BAYA_USE_RECONNECTING_CLIENT = True

Admin configuration

The django admin requires that users logging in have the is_staff flag set. You should add this config to your settings if you use the django admin. See also admin.

AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    'is_staff': ['cn=myapp_admin,ou=Access,dc=example,dc=com'],
}

Testing access permissions

You will not always have a connection to your production LDAP server, so Baya supports a couple ways to develop locally and test your views.

Disable all permissions checking

Be sure to never deploy this setting to production!

The easiest way to test your app locally is to just disable Baya entirely. You can do this by enabling this setting:

# settings.py
BAYA_ALLOW_ALL = True

This will allow all requests to your protected views and is useful if you're just testing that your view works, but don't currently care about the access restrictions.

One drawback to this is that you cannot test admin views, due to the way django admin interacts with django-auth-ldap. It just has to have an LDAP directory to read from. If your views are protected admin views, then go to the next section "Use python-mock-ldap".

Use python-mock-ldap

If you want to test a few views you can use mockldap. Place the following lines in your urls.py file so it runs on django startup.

# urls.py

patterns = (...)

from baya.mock_ldap_helpers import mock_ldap_setup

mock_ldap_instance = mock_ldap_setup(
    extra_users=[
        ('my_user', 'group_1'),
        ('my_user', 'group_b'),
        ('other_user', 'group_a'),
    ],
    group_lineage=[
        ('group_a', 'group_b'),  # group_b is a child of group_a
    ]
)
mock_ldap_instance.start()

# And you must update the ldap bind password to use the fake one
from django.conf import settings
settings.AUTH_LDAP_BIND_PASSWORD = 'password'

Keep in mind that if you are manually setting groups in your test fixtures django-auth-ldap will overwrite all of that user's groups with the groups in LDAP. If your tests suddenly break due to group permissions problems, that's a likely cause. For this reason I recommend you create new test users for each app.

For a more complete example, see baya.tests.directory.

Usage

Syntax

baya.permissions.requires([groups, get, post])

  • groups: A baya.membership.BaseNode child which all GET and POST requests must pass.
  • get: A baya.membership.BaseNode child which all GET requests must pass. AND-ed with the groups parameter.
  • post: A baya.membership.BaseNode child which all POST requests must pass. AND-ed with the groups parameter.

Note that if you specify groups and get or post, then the roles are &-ed together. This lets you specify roles common to both GET and POST requests, as well as further restrict each method accordingly.

requires returns a function which takes your view or urlpattern as its only argument.

from baya.permissions import requires
from baya.membership import RolesNode as g

admin = g('admin')
billing_ro = g('billing_ro')
customer_service = g('customer_service')

# Only an admin may access this view
requires(admin)(view_or_url)

# Anyone with 'billing_ro' permissions may access this view, but only an admin
# may post. These two declarations result in the same behavior:
requires(billing_ro, post=admin)(view_or_url)
requires(get=billing_ro, post=(billing_ro & admin))

# Customer service or anyone with billing_ro role may access the view, but only
# an admin or customer service may post
requires(get=(customer_service | billing_ro), post=(admin | customer_service))(view_or_url)

DENY_ALL

For convenience, there's a DENY_ALL permissions node which you can use to completely disable access to a view using a given class of HTTP verbs.

from baya.permissions import requires
from baya.permissions import DENY_ALL
from baya.membership import RolesNode as g

@requires(get=g('billing'), post=DENY_ALL)
def my_view(request):
    ...

urls.py

You can protect URLs individually or an entire import:

Decorating the URLs is the preferred usage, since decorating the view methods themselves makes you hunt around for the permissions.

from django.conf.urls import url
from django.views.generic import ListView

from baya import requires
from baya import RolesNode as g

from .models import Blag


urlpatterns = [
    # Protect a single view
    url(r'^$', requires(g('group1'), post=g('group2'))(ListView.as_view(model=Blag))),
    # Protect an entire URL module include
    url(r'^billing/', requires(get=g('billing_ro'), post=g('billing'))(include('my_app.billing.urls'))),
]

Note Typing the same g('my_group') over and over is tedious, verbose, and prone to typos. A better pattern is to define the groups you'll be using as constants at the module level:

from django.conf.urls import url
from django.views.generic import ListView

from baya import requires
from baya import RolesNode as g

from .models import Blag
from .models import Entry

GROUP1 = g('group1')
GROUP2 = g('group2')
BILLING = g('billing')
BILLING_RO = g('billing_ro')

SUPER_GROUP = GROUP1 & GROUP2

urlpatterns = [
    # Protect a single view
    url(r'^$', requires(GROUP1, post=GROUP2)(ListView.as_view(model=Blag))),
    url(r'^super/$', requires(SUPER_GROUP)(ListView.as_view(model=Entry))),
    # Protect an entire URL module include
    url(r'^billing/', requires(get=BILLING_RO, post=BILLING)(include('my_app.billing.urls'))),
]

views.py

Decorate regular method-based views. Avoid this if possible, preferring url decoration.

from django.http import HttpResponse

from baya import requires
from baya import RolesNode as g


@requires(g('A'))
def my_simple_view(request):
    return HttpResponse("my_simple_view response")

@requires(g('Aaa'), get=g('A_RO') | g('B_RO'), post=g('A') | g('B'))
def my_view(request):
    return HttpResponse("my_view response")

admin

The admin site takes a little more work. Rather than use django.contrib.admin.site you will instead have to instantiate baya.admin.sites.NestedGroupsAdminSite. You will also have to use the baya.admin.BayaModelAdmin in your ModelAdmin classes.

You have a couple more options with django admin option classes than you normally do with regular views. You can specify different permissions for the django admin's create, read, update, and delete views.

Note that you can also decorate the ModelAdmins individually or wrap them in requires at site.register time if you want a given permission to apply to every request, and not just a particular CRUD verb.

from django.contrib import admin
from django.conf.urls import url
from django.shortcuts import render

from baya import requires
from baya import RolesNode as g
from baya.admin import BayaModelAdmin
from baya.admin.sites import NestedGroupsAdminSite
from baya.permissions import DENY_ALL

from testsite.example.models import Blag
from testsite.example.models import Entry


@requires(get=g('Aaa'), post=g('A'))
class BlagOptions(BayaModelAdmin):
    fields = list_display = ['name']

    def get_urls(self):
        urls = super(BlagOptions, self).get_urls()

        # This inner url ends up protected like this:
        # requires(get="Aaa", post="a")(requires("B")(self.inner))
        urls += [
            url(r'inner_admin_view',
                requires(g('B'))(self.inner),
                name='inner')
        ]
        return urls

    def inner(self, request):
        return render(...)


class EntryOptions(BayaModelAdmin):
    DELETE = DENY_ALL


class CommentOptions(BayaModelAdmin):
    CREATE = g('A')
    READ = g('Aaa')
    UPDATE = g('Aa')
    DELETE = DENY_ALL


site = NestedGroupsAdminSite(name='example')
site.register(Blag, BlagOptions)
site.register(Entry, requires(g('Aa'))(EntryOptions))
site.register(Comment, CommentOptions)

Note that all of the urls returned by your model admin's get_urls method will be protected with the appropriate permissions. You can further restrict admin inner urls by using the requires decorator there.

You must also add configuration for the is_staff flag. See admin configuration.

Development Set Up

Django

First check this out and install the requirements:

make setup
cd testsite
./manage.py syncdb

If installation of python-ldap fails on Mac OSX with fatal error: 'sasl.h' file not found or similar missing header files then run the following to manualy install python-ldap.

source .venv/bin/activate
pip install python-ldap \
   --global-option=build_ext \
   --global-option="-I$(xcrun --show-sdk-path)/usr/include/sasl"

Now make setup should work.

Run

# in testsite/
./manage.py runserver

Runserver and log in to http://localhost:8000/example/ using one of the mock users in the directory module. Play around with the @requires decorator in tests.testsite.example.admin to see the ldap authorization working.

Testing

make test

baya's People

Contributors

ampacker avatar dependabot[bot] avatar hrichards avatar jdavisp3 avatar jtratner avatar lucaswiman avatar naegelyd avatar richardxia avatar

Stargazers

 avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

baya's Issues

BAYA_ALLOW_ALL = True allows unauthenticated users

(Copied from internal github, original issue by rxia)

@sbuss

I'm not sure if this was intentional, but if you set BAYA_ALLOW_ALL = True, then views decorated with @requires() will allow unauthenticated users to log in. Do you think it's reasonable to change this so that it still forces you to be logged in as someone, but not do any actual checking on the groups?

I know that the use of BAYA_ALLOW_ALL = True is discouraged, but I was making changes in an app I'm not familiar with, and I didn't want to figure out the exact permission required. One of the pages I visited was raising an exception because it assumed that request.user was a valid, authenticated user, which is normally safe to make because of the @requires() decorator. However, because I had BAYA_ALLOW_ALL = True, the decorator actually let an unauthenticated user through.

I think the only thing required to make this change is to move this block of code over to this line. Alternatively, we could also redefine _has_permission() to mean "is logged in and has permission" and short-circuit before the group check if the user is not logged in. I'd be more than happy to submit a PR if you agree with this change.

has_permission fails when using DjangoRequestGroupFormatter

(copied from internal github, original issue opened by alejandro)

baya.utils.has_permission fails with the following error messages when view function (param fn) is wrapped like so: requires(DynamicRolesNode(DjangoRequestGroupFormatter('%s', 'name'))

Traceback (most recent call last):
  File "/Users/alejandro/Projects/baya/baya/tests/test_group_formatter.py", line 16, in test_has_permission_url_param
    perm = has_permission(view_func, AnonymousUser(), 'any')
  File "/Users/alejandro/Projects/baya/baya/utils.py", line 60, in has_permission
    if check_perm(user):
  File "/Users/alejandro/Projects/baya/baya/permissions.py", line 106, in user_has_any_permission
    return (self.user_has_get_permission(user) or
  File "/Users/alejandro/Projects/baya/baya/permissions.py", line 93, in user_has_get_permission
    return self._has_permission(user, self.get_requires)
  File "/Users/alejandro/Projects/baya/baya/permissions.py", line 87, in _has_permission
    return user_in_group(user, membership_node, request=request)
  File "/Users/alejandro/Projects/baya/baya/utils.py", line 26, in user_in_group
    return PermissionChecker(user_groups).visit(group, **kwargs)
  File "/Users/alejandro/Projects/baya/baya/visitors.py", line 27, in visit
    return self._visit_roles_node(node, **kwargs)
  File "/Users/alejandro/Projects/baya/baya/visitors.py", line 182, in _visit_roles_node
    return roles_node.get_roles_set(**kwargs) <= self._roles_set
  File "/Users/alejandro/Projects/baya/baya/membership.py", line 142, in get_roles_set
    result = _callable(**kwargs)
  File "/Users/alejandro/Projects/baya/baya/dynamic_roles.py", line 63, in __call__
    url_kwargs = request.resolver_match.kwargs
AttributeError: 'NoneType' object has no attribute 'resolver_match'

To recreate this, checkout branch has_permission_breaks_drgf and run make test.

I discovered this when I was more thoroughly testing the new templatetag can_user_perform_action.

Why is this happening?

DynamicRolesNode stores callables that return sets of groups that are called runtime when permission checking occurs. When that callable is a DjangoRequestGroupFormatter, problems occur because there is no request object to grab kwargs from for formatting the groups to return.

How to fix?

Probably make the DjangoRequestGroupFormatter not dependent on the request object to do the url parsing for it. There's got to be another way to pull out the kwargs and params of a url without doing it through a request.

I'll be trying to figure out how to solve this the has_permission_breaks_drgf branch if the above solution sounds right.

Scratch that. I'm not sure how to fix this...

admin.site.urls autodiscovery doesn't work quite right

The normal django pattern of admin.site.urls for admin urls doesn't work with baya. I'm pretty sure this is a problem with autodiscovery.

Docs fix: You need to do from myapp import admin as my_admin; my_admin.site.urls at the bottom of your admin.py file. This should be documented, but it's better to fix the core issue.

Long fix: make autodiscovery work properly

Improve integration with django-admin

There are some rough edges with integration of baya with django-admin:

  • The READ permissions for BayaInlineMixin do not work correctly. AFAICT, a user who does not have UPDATE permissions also does not have READ permissions. This can be remedied by overriding get_readonly_fields to return all fields if the user lacks update permissions, and has_change_permission to allow users with update or read permissions.
  • Fields in a changeview still show up as form elements instead of their readonly display for users who lack UPDATE permissions. A better UX would be to show them as readonly by using get_readonly_fields.
  • The "save" buttons still show up for users who lack permissions to update the object. This can lead to user confusion. See this relevant, if hacky, StackOverflow post.
  • Nice to have: field-level access controls (Read/update). This would afford disallowing some users from seeing or updating sensitive fields, while still allowing them to see/update others. I think this can be implemented by overriding BayaModelAdmin.get_readonly_fields and BayaModelAdmin.get_fieldsets.

cc @hrichards @jdavisp3

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.