diff --git a/.coveragerc b/.coveragerc index 43d4b1a8..9e31bd50 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,6 +11,7 @@ source = hrapplications notifications optimer + permissions_tool services srp timerboard diff --git a/alliance_auth/settings.py.example b/alliance_auth/settings.py.example index f4a80609..c7a0e9de 100644 --- a/alliance_auth/settings.py.example +++ b/alliance_auth/settings.py.example @@ -65,6 +65,7 @@ INSTALLED_APPS = [ 'fleetactivitytracking', 'notifications', 'esi', + 'permissions_tool', 'geelweb.django.navhelper', 'bootstrap_pagination', diff --git a/alliance_auth/tests/test_settings.py b/alliance_auth/tests/test_settings.py index f150cfd4..b3531289 100644 --- a/alliance_auth/tests/test_settings.py +++ b/alliance_auth/tests/test_settings.py @@ -54,6 +54,7 @@ INSTALLED_APPS = [ 'fleetactivitytracking', 'notifications', 'esi', + 'permissions_tool', 'geelweb.django.navhelper', 'bootstrap_pagination', 'services.modules.mumble', @@ -141,7 +142,7 @@ SUPERUSER_STATE_BYPASS = 'True' == os.environ.get('AA_SUPERUSER_STATE_BYPASS', ' # Internationalization # https://docs.djangoproject.com/en/1.10/topics/i18n/ -LANGUAGE_CODE = os.environ.get('AA_LANGUAGE_CODE', 'en-us') +LANGUAGE_CODE = os.environ.get('AA_LANGUAGE_CODE', 'en') TIME_ZONE = os.environ.get('AA_TIME_ZONE', 'UTC') diff --git a/alliance_auth/urls.py b/alliance_auth/urls.py index b857d402..9f3c2a64 100755 --- a/alliance_auth/urls.py +++ b/alliance_auth/urls.py @@ -17,6 +17,7 @@ import notifications.views import hrapplications.views import corputils.urls import esi.urls +import permissions_tool.urls from alliance_auth import NAME admin.site.site_header = NAME @@ -211,6 +212,8 @@ urlpatterns += i18n_patterns( url(r'^fat/link/$', fleetactivitytracking.views.fatlink_view, name='auth_click_fatlink_view'), url(r'^fat/link/(?P[a-zA-Z0-9]+)/(?P[a-z0-9_-]+)/$', fleetactivitytracking.views.click_fatlink_view), + + url(r'^permissions/', include(permissions_tool.urls)) ) # Append hooked service urls diff --git a/docs/features/index.md b/docs/features/index.md index c11f0b2d..fad1b5c6 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -3,8 +3,9 @@ ```eval_rst .. toctree:: :maxdepth: 1 - :caption: Contents + :caption: Features Contents hrapplications corpstats + permissions_tool ``` diff --git a/docs/features/permissions_tool.md b/docs/features/permissions_tool.md new file mode 100644 index 00000000..016bf972 --- /dev/null +++ b/docs/features/permissions_tool.md @@ -0,0 +1,39 @@ +# Permissions Auditing + +```eval_rst +.. note:: + New in 1.15 +``` + +Access to most of Alliance Auth's features are controlled by Django's permissions system. In order to help you secure your services, Alliance Auth provides a permissions auditing tool. + +### Access + +In order to grant users access to the permissions auditing tool they will need to be granted the `permissions_tool.audit_permissions` permission or be a superuser. + +When a user has access to the tool they will see the "Permissions Audit" menu item under the "Util" sub menu. + + +### Permissions Overview + +The first page gives you a general overview of permissions and how many users have access to each permission. + +![permissions overview](https://i.imgur.com/XALVFtc.png) + +**App**, **Model** and **Code Name** contain the internal details of the permission while **Name** contains the name/description you'll see in the admin panel. + +**Users** is the number of users explicitly granted this permission on their account. + +**Groups** is the number of groups with this permission assigned. + +**Groups Users** is the total number of users in all of the groups with this permission assigned. + +Clicking on the **Code Name** link will take you to the [Permissions Audit Page](#permissions-audit-page) + +### Permissions Audit Page + +The permissions audit page will give you an overview of all the users who have access to this permission either directly or granted via group membership. + +![permissions audit](https://i.imgur.com/XjnfC9Z.png) + +Please note that users may appear multiple times if this permission is granted via multiple sources. diff --git a/permissions_tool/__init__.py b/permissions_tool/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/permissions_tool/apps.py b/permissions_tool/apps.py new file mode 100644 index 00000000..487a8b63 --- /dev/null +++ b/permissions_tool/apps.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class ServicesConfig(AppConfig): + name = 'permissions_tool' + diff --git a/permissions_tool/auth_hooks.py b/permissions_tool/auth_hooks.py new file mode 100644 index 00000000..9fa82433 --- /dev/null +++ b/permissions_tool/auth_hooks.py @@ -0,0 +1,20 @@ +from services.hooks import MenuItemHook +from alliance_auth import hooks + + +class PermissionsTool(MenuItemHook): + def __init__(self): + MenuItemHook.__init__(self, + 'Permissions Audit', + 'fa fa-key fa-id-card grayiconecolor', + 'permissions_overview', 400) + + def render(self, request): + if request.user.has_perm('permissions_tool.audit_permissions'): + return MenuItemHook.render(self, request) + return '' + + +@hooks.register('menu_util_hook') +def register_menu(): + return PermissionsTool() diff --git a/permissions_tool/migrations/0001_initial.py b/permissions_tool/migrations/0001_initial.py new file mode 100644 index 00000000..8f6a4cf4 --- /dev/null +++ b/permissions_tool/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-06 08:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='PermissionsTool', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'managed': False, + 'permissions': (('audit_permissions', 'Can audit permissions'),), + }, + ), + ] diff --git a/permissions_tool/migrations/__init__.py b/permissions_tool/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/permissions_tool/models.py b/permissions_tool/models.py new file mode 100644 index 00000000..3a93f94b --- /dev/null +++ b/permissions_tool/models.py @@ -0,0 +1,12 @@ +from django.db import models + + +class PermissionsTool(models.Model): + """ + Dummy model for holding permissions + """ + class Meta: + managed = False + permissions = ( + ('audit_permissions', 'Can audit permissions'), + ) diff --git a/permissions_tool/templates/permissions_tool/audit.html b/permissions_tool/templates/permissions_tool/audit.html new file mode 100644 index 00000000..076034cc --- /dev/null +++ b/permissions_tool/templates/permissions_tool/audit.html @@ -0,0 +1,39 @@ +{% extends "public/base.html" %} +{% load bootstrap %} +{% load staticfiles %} +{% load i18n %} + +{% block page_title %}{{ permission.permission.codename }} - {% trans "Permissions Audit" %}{% endblock page_title %} + +{% block content %} +
+

{% trans "Permissions Audit" %}: {{ permission.permission.codename }}

+ + {% trans "Back" %} + + + + + + + + + + {% for user in permission.users %} + + {% include 'permissions_tool/audit_row.html' with group="Permission Granted Directly (No Group)" %} + + {% endfor %} + {% for group in permission.groups %} + {% for user in group.user_set.all %} + {% include 'permissions_tool/audit_row.html' %} + {% endfor %} + {% endfor %} + +
+ {% trans "Group" %} + + {% trans "User" %} +
+
+{% endblock content %} diff --git a/permissions_tool/templates/permissions_tool/audit_row.html b/permissions_tool/templates/permissions_tool/audit_row.html new file mode 100644 index 00000000..7431556b --- /dev/null +++ b/permissions_tool/templates/permissions_tool/audit_row.html @@ -0,0 +1,10 @@ + + + {% if forloop.first %} + {{ group }} + {% endif %} + + + {{ user }} + + diff --git a/permissions_tool/templates/permissions_tool/overview.html b/permissions_tool/templates/permissions_tool/overview.html new file mode 100644 index 00000000..8a31685a --- /dev/null +++ b/permissions_tool/templates/permissions_tool/overview.html @@ -0,0 +1,79 @@ +{% extends "public/base.html" %} +{% load bootstrap %} +{% load staticfiles %} +{% load i18n %} + +{% block page_title %}{% trans "Permissions Overview" %}{% endblock page_title %} + +{% block content %} +
+

{% trans "Permissions Overview" %}

+ {% if request.GET.all != 'yes' %} + + {% blocktrans %}Showing only applied permissions{% endblocktrans %} + {% trans "Show All" %} + + {% else %} + + {% blocktrans %}Showing all permissions{% endblocktrans %} + {% trans "Show Applied" %} + + {% endif %} + + + + + + + + + + + + + + {% for perm in permissions %} + + + + + + + + + + {% endfor %} + +
+ {% trans "App" %} + + {% trans "Model" %} + + {% trans "Code Name" %} + + {% trans "Name" %} + + {% trans "Users" %} + + {% trans "Groups" %} + + {% trans "Groups Users" %} +
+ {{ perm.permission.content_type.app_label }} + + {{ perm.permission.content_type.model }} + + + {{ perm.permission.codename }} + + + {{ perm.permission.name }} + + {{ perm.users }} + + {{ perm.groups }} + + {{ perm.group_users }} +
+
+{% endblock content %} diff --git a/permissions_tool/tests.py b/permissions_tool/tests.py new file mode 100644 index 00000000..fcd1b602 --- /dev/null +++ b/permissions_tool/tests.py @@ -0,0 +1,122 @@ +from __future__ import unicode_literals + +try: + # Py3 + from unittest import mock +except ImportError: + # Py2 + import mock + +from django.test import TestCase +from django import urls +from django.contrib.auth.models import User, Group, Permission + +from alliance_auth.tests.auth_utils import AuthUtils + +import six + + +class PermissionsToolViewsTestCase(TestCase): + def setUp(self): + self.member = AuthUtils.create_member('auth_member') + self.member.set_password('password') + self.member.email = 'auth_member@example.com' + self.member.save() + self.none_user = AuthUtils.create_user('none_user', disconnect_signals=True) + self.none_user2 = AuthUtils.create_user('none_user2', disconnect_signals=True) + self.none_user3 = AuthUtils.create_user('none_user3', disconnect_signals=True) + + self.no_perm_user = AuthUtils.create_user('no_perm_user', disconnect_signals=True) + self.no_perm_user.set_password('password') + + AuthUtils.disconnect_signals() + self.no_perm_group = Group.objects.create(name="No Permission Group") + + self.test_group = Group.objects.create(name="Test group") + + self.test_group.user_set.add(self.none_user) + self.test_group.user_set.add(self.none_user2) + self.test_group.user_set.add(self.none_user3) + + self.permission = Permission.objects.get_by_natural_key(codename='audit_permissions', + app_label='permissions_tool', + model='permissionstool') + + self.test_group.permissions.add(self.permission) + self.member.user_permissions.add(self.permission) + AuthUtils.connect_signals() + + def test_menu_item(self): + self.client.login(username=self.member.username, password='password') + + response = self.client.get(urls.reverse('permissions_overview')) + + response_content = response.content + if six.PY3: + response_content = str(response_content, encoding='utf8') + + self.assertInHTML('
  • Permissions Audit
  • ', response_content) + + def test_permissions_overview(self): + self.client.login(username=self.member.username, password='password') + + response = self.client.get(urls.reverse('permissions_overview')) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed('permissions_tool/overview.html') + self.assertContains(response, self.permission.codename) + self.assertContains(response, self.permission.content_type.app_label) + self.assertContains(response, self.permission.content_type.model) + + tested_context = False + # Test the context + for perm in response.context['permissions']: + if perm['permission'] == self.permission: + tested_context = True + self.assertDictContainsSubset({'users': 1}, perm) + self.assertDictContainsSubset({'groups': 1}, perm) + self.assertDictContainsSubset({'group_users': 3}, perm) + break + self.assertTrue(tested_context) + + def test_permissions_overview_perms(self): + # Ensure permission effectively denys access + self.client.login(username=self.no_perm_user.username, password='password') + + response = self.client.get(urls.reverse('permissions_overview')) + + self.assertEqual(response.status_code, 302) + + def test_permissions_audit(self): + self.client.login(username=self.member.username, password='password') + + response = self.client.get(urls.reverse('permissions_audit', + kwargs={ + 'app_label': self.permission.content_type.app_label, + 'model': self.permission.content_type.model, + 'codename': self.permission.codename, + })) + + self.assertTemplateUsed('permissions_tool/audit.html') + self.assertTemplateUsed('permissions_tool/audit_row.html') + + self.assertContains(response, self.permission.codename) + self.assertContains(response, self.none_user) + self.assertContains(response, self.none_user3) + self.assertContains(response, self.test_group) + + self.assertNotContains(response, self.no_perm_user) + + def test_permissions_audit_perms(self): + # Ensure permission effectively denys access + self.client.login(username=self.no_perm_user.username, password='password') + + response = self.client.get(urls.reverse('permissions_audit', + kwargs={ + 'app_label': self.permission.content_type.app_label, + 'model': self.permission.content_type.model, + 'codename': self.permission.codename, + })) + + self.assertEqual(response.status_code, 302) diff --git a/permissions_tool/urls.py b/permissions_tool/urls.py new file mode 100644 index 00000000..e144c0de --- /dev/null +++ b/permissions_tool/urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^overview/$', views.permissions_overview, name='permissions_overview'), + url(r'^audit/(?P[\w\-_]+)/(?P[\w\-_]+)/(?P[\w\-_]+)/$', views.permissions_audit, + name='permissions_audit'), +] diff --git a/permissions_tool/views.py b/permissions_tool/views.py new file mode 100644 index 00000000..c0e95f77 --- /dev/null +++ b/permissions_tool/views.py @@ -0,0 +1,55 @@ +from __future__ import unicode_literals +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.auth.models import Permission +from django.shortcuts import render, get_object_or_404 +from django.db.models import Count + +import logging + +logger = logging.getLogger(__name__) + + +@login_required +@permission_required('permissions_tool.audit_permissions') +def permissions_overview(request): + logger.debug("permissions_overview called by user %s" % request.user) + perms = Permission.objects.all() + + get_all = True if request.GET.get('all', 'no') == 'yes' else False + + context = {'permissions': []} + for perm in perms: + this_perm = { + 'users': perm.user_set.all().count(), + 'groups': perm.group_set.all().count(), + 'permission': perm + } + + if get_all or this_perm['users'] > 0 or this_perm['groups'] > 0: + # Only add if we're getting everything or one of the objects has this permission + # Add group_users separately to improve performance + this_perm['group_users'] = sum(group.user_count for group in + perm.group_set.annotate(user_count=Count('user'))) + context['permissions'].append(this_perm) + + return render(request, 'permissions_tool/overview.html', context=context) + + +@login_required +@permission_required('permissions_tool.audit_permissions') +def permissions_audit(request, app_label, model, codename): + logger.debug("permissions_audit called by user {} on {}:{}:{}".format(request.user, app_label, model, codename)) + perm = get_object_or_404(Permission, + content_type__app_label=app_label, + content_type__model=model, + codename=codename) + + context = {'permission': { + 'permission': perm, + 'users': perm.user_set.all(), + 'groups': perm.group_set.all(), + 'group_users': [group.user_set.all() for group in perm.group_set.all()] + } + } + + return render(request, 'permissions_tool/audit.html', context=context) diff --git a/stock/templates/public/base.html b/stock/templates/public/base.html index 6b8db608..dc5dd734 100755 --- a/stock/templates/public/base.html +++ b/stock/templates/public/base.html @@ -13,7 +13,7 @@ - {% block title %}Alliance Auth{% endblock title %} + {% block title %}{% block page_title %}{% endblock page_title %} - Alliance Auth{% endblock title %}