Enforce and test TODO_STAFF_ONLY setting

This commit is contained in:
Scot Hacker 2019-01-10 00:39:21 -08:00
parent 6953085285
commit 91b9a099a3
14 changed files with 81 additions and 51 deletions

View file

@ -30,7 +30,7 @@ The assumption is that your organization/publication/company has multiple groups
You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo. You must have at least one Group set up in Django admin, and that group must have at least one User as a member. This is true even if you're the sole user of django-todo.
Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff()` can add or delete lists. Users can view and modify all to-do lists belonging to their group(s). Only users with `is_staff` can add or delete lists.
Identical list names can exist in different groups, but not in the same group. Identical list names can exist in different groups, but not in the same group.
@ -108,8 +108,9 @@ If you wish to use the public ticket-filing system, first create the list into w
Optional configuration options: Optional configuration options:
``` ```
# Restrict access to todo lists/views to `is_staff()` users. # Restrict access to ALL todo lists/views to `is_staff` users.
# False here falls back to `is_authenticated()` users. # If False or unset, all users can see all views (but more granular permissions are still enforced
# within views, such as requiring staff for adding and deleting lists).
TODO_STAFF_ONLY = True TODO_STAFF_ONLY = True
# If you use the "public" ticket filing option, to whom should these tickets be assigned? # If you use the "public" ticket filing option, to whom should these tickets be assigned?
@ -167,6 +168,8 @@ The previous `tox` system was removed with the v2 release, since we no longer ai
# Version History # Version History
**2.2.0** Re-instate enforcement of TODO_STAFF_ONLY setting
**2.1.1** Correct Python version requirement in documentation to Python 3.6 **2.1.1** Correct Python version requirement in documentation to Python 3.6
**2.1.1** Split up views into separate modules. **2.1.1** Split up views into separate modules.

View file

@ -1,7 +1,7 @@
""" """
A multi-user, multi-group task management and assignment system for Django. A multi-user, multi-group task management and assignment system for Django.
""" """
__version__ = '2.1.2' __version__ = '2.2.0'
__author__ = 'Scot Hacker' __author__ = 'Scot Hacker'
__email__ = 'shacker@birdhouse.org' __email__ = 'shacker@birdhouse.org'

View file

@ -144,18 +144,11 @@ def test_no_javascript_in_comments(todo_setup, client):
# ### PERMISSIONS ### # ### PERMISSIONS ###
"""
Some views are for staff users only.
We've already smoke-tested with Admin user - try these with normal user.
These exercise our custom @staff_only decorator without calling that function explicitly.
"""
def test_view_add_list_nonadmin(todo_setup, client): def test_view_add_list_nonadmin(todo_setup, client):
url = reverse("todo:add_list") url = reverse("todo:add_list")
client.login(username="you", password="password") client.login(username="you", password="password")
response = client.get(url) response = client.get(url)
assert response.status_code == 403 assert response.status_code == 302 # Redirected to login
def test_view_del_list_nonadmin(todo_setup, client): def test_view_del_list_nonadmin(todo_setup, client):
@ -163,7 +156,7 @@ def test_view_del_list_nonadmin(todo_setup, client):
url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug}) url = reverse("todo:del_list", kwargs={"list_id": tlist.id, "list_slug": tlist.slug})
client.login(username="you", password="password") client.login(username="you", password="password")
response = client.get(url) response = client.get(url)
assert response.status_code == 403 assert response.status_code == 302 # Fedirected to login
def test_view_list_mine(todo_setup, client): def test_view_list_mine(todo_setup, client):
@ -220,3 +213,22 @@ def test_view_task_not_in_my_group(todo_setup, client):
response = client.get(url) response = client.get(url)
assert response.status_code == 403 assert response.status_code == 403
def test_setting_TODO_STAFF_ONLY_False(todo_setup, client, settings):
# We use Django's user_passes_test to call `staff_check` utility function on all views.
# Just testing one view here; if it works, it works for all of them.
settings.TODO_STAFF_ONLY = False
url = reverse("todo:lists")
client.login(username="u2", password="password")
response = client.get(url)
assert response.status_code == 200
def test_setting_TODO_STAFF_ONLY_True(todo_setup, client, settings):
# We use Django's user_passes_test to call `staff_check` utility function on all views.
# Just testing one view here; if it works, it works for all of them.
settings.TODO_STAFF_ONLY = True
url = reverse("todo:lists")
client.login(username="u2", password="password")
response = client.get(url)
assert response.status_code == 302 # Redirected to login view

View file

@ -1,26 +1,22 @@
from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail from django.core.mail import send_mail
from django.template.loader import render_to_string from django.template.loader import render_to_string
from todo.models import Comment, Task from todo.models import Comment, Task
def staff_only(function): def staff_check(user):
""" """If TODO_STAFF_ONLY is set to True, limit view access to staff users only.
Custom view decorator allows us to raise 403 on insufficient permissions, # FIXME: More granular access control is needed... but need to do it generically,
rather than redirect user to login view. # to satisfy all possible todo implementations.
""" """
def wrap(request, *args, **kwargs): if hasattr(settings, "TODO_STAFF_ONLY") and settings.TODO_STAFF_ONLY:
if request.user.is_staff: return user.is_staff
return function(request, *args, **kwargs)
else: else:
raise PermissionDenied # If unset or False, allow all logged in users
return True
wrap.__doc__ = function.__doc__
wrap.__name__ = function.__name__
return wrap
def send_notify_mail(new_task): def send_notify_mail(new_task):

View file

@ -1,20 +1,25 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.text import slugify from django.utils.text import slugify
from todo.forms import AddTaskListForm from todo.forms import AddTaskListForm
from todo.utils import staff_only from todo.utils import staff_check
@staff_only
@login_required @login_required
@user_passes_test(staff_check)
def add_list(request) -> HttpResponse: def add_list(request) -> HttpResponse:
"""Allow users to add a new todo list to the group they're in. """Allow users to add a new todo list to the group they're in.
""" """
# Only staffers can add lists.
if not request.user.is_staff:
raise PermissionDenied
if request.POST: if request.POST:
form = AddTaskListForm(request.user, request.POST) form = AddTaskListForm(request.user, request.POST)
if form.is_valid(): if form.is_valid():
@ -33,6 +38,7 @@ def add_list(request) -> HttpResponse:
) )
else: else:
if request.user.groups.all().count() == 1: if request.user.groups.all().count() == 1:
# FIXME: Assuming first of user's groups here; better to prompt for group
form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]}) form = AddTaskListForm(request.user, initial={"group": request.user.groups.all()[0]})
else: else:
form = AddTaskListForm(request.user) form = AddTaskListForm(request.user)

View file

@ -1,25 +1,22 @@
import datetime
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from todo.models import Task, TaskList from todo.models import Task, TaskList
from todo.utils import staff_only from todo.utils import staff_check
@staff_only
@login_required @login_required
@user_passes_test(staff_check)
def del_list(request, list_id: int, list_slug: str) -> HttpResponse: def del_list(request, list_id: int, list_slug: str) -> HttpResponse:
"""Delete an entire list. Only staff members should be allowed to access this view. """Delete an entire list. Only staff members should be allowed to access this view.
""" """
task_list = get_object_or_404(TaskList, id=list_id) task_list = get_object_or_404(TaskList, id=list_id)
# Ensure user has permission to delete list. Admins can delete all lists. # Ensure user has permission to delete list. Get the group this list belongs to,
# Get the group this list belongs to, and check whether current user is a member of that group. # and check whether current user is a member of that group AND a staffer.
# FIXME: This means any group member can delete lists, which is probably too permissive.
if task_list.group not in request.user.groups.all() and not request.user.is_staff: if task_list.group not in request.user.groups.all() and not request.user.is_staff:
raise PermissionDenied raise PermissionDenied

View file

@ -1,13 +1,16 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from todo.models import Task from todo.models import Task
from todo.utils import staff_check
@login_required @login_required
@user_passes_test(staff_check)
def delete_task(request, task_id: int) -> HttpResponse: def delete_task(request, task_id: int) -> HttpResponse:
"""Delete specified task. """Delete specified task.
Redirect to the list from which the task came. Redirect to the list from which the task came.

View file

@ -1,6 +1,6 @@
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.mail import send_mail from django.core.mail import send_mail
@ -10,9 +10,11 @@ from django.template.loader import render_to_string
from todo.forms import AddExternalTaskForm from todo.forms import AddExternalTaskForm
from todo.models import TaskList from todo.models import TaskList
from todo.utils import staff_check
@login_required @login_required
@user_passes_test(staff_check)
def external_add(request) -> HttpResponse: def external_add(request) -> HttpResponse:
"""Allow authenticated users who don't have access to the rest of the ticket system to file a ticket """Allow authenticated users who don't have access to the rest of the ticket system to file a ticket
in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where in the list specified in settings (e.g. django-todo can be used a ticket filing system for a school, where

View file

@ -1,6 +1,6 @@
import bleach import bleach
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
@ -8,10 +8,11 @@ from django.utils import timezone
from todo.forms import AddEditTaskForm from todo.forms import AddEditTaskForm
from todo.models import Task, TaskList from todo.models import Task, TaskList
from todo.utils import send_notify_mail from todo.utils import send_notify_mail, staff_check
@login_required @login_required
@user_passes_test(staff_check)
def list_detail(request, list_id=None, list_slug=None, view_completed=False) -> HttpResponse: def list_detail(request, list_id=None, list_slug=None, view_completed=False) -> HttpResponse:
"""Display and manage tasks in a todo list. """Display and manage tasks in a todo list.
""" """

View file

@ -1,15 +1,17 @@
import datetime import datetime
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from todo.forms import SearchForm from todo.forms import SearchForm
from todo.models import Task, TaskList from todo.models import Task, TaskList
from todo.utils import staff_check
@login_required @login_required
@user_passes_test(staff_check)
def list_lists(request) -> HttpResponse: def list_lists(request) -> HttpResponse:
"""Homepage view - list of lists a user can view, and ability to add a list. """Homepage view - list of lists a user can view, and ability to add a list.
""" """

View file

@ -1,12 +1,14 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.http import HttpResponse from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from todo.models import Task from todo.models import Task
from django.views.decorators.csrf import csrf_exempt from todo.utils import staff_check
@csrf_exempt @csrf_exempt
@login_required @login_required
@user_passes_test(staff_check)
def reorder_tasks(request) -> HttpResponse: def reorder_tasks(request) -> HttpResponse:
"""Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html """Handle task re-ordering (priorities) from JQuery drag/drop in list_detail.html
""" """

View file

@ -1,18 +1,22 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from todo.models import Task from todo.models import Task
from todo.utils import staff_check
@login_required @login_required
@user_passes_test(staff_check)
def search(request) -> HttpResponse: def search(request) -> HttpResponse:
"""Search for tasks user has permission to see. """Search for tasks user has permission to see.
""" """
if request.GET:
query_string = "" query_string = ""
if request.GET:
found_tasks = None found_tasks = None
if ("q" in request.GET) and request.GET["q"].strip(): if ("q" in request.GET) and request.GET["q"].strip():
query_string = request.GET["q"] query_string = request.GET["q"]
@ -29,7 +33,6 @@ def search(request) -> HttpResponse:
found_tasks = found_tasks.exclude(completed=True) found_tasks = found_tasks.exclude(completed=True)
else: else:
query_string = None
found_tasks = None found_tasks = None
# Only include tasks that are in groups of which this user is a member: # Only include tasks that are in groups of which this user is a member:

View file

@ -2,17 +2,18 @@ import datetime
import bleach import bleach
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from todo.forms import AddEditTaskForm from todo.forms import AddEditTaskForm
from todo.models import Comment, Task from todo.models import Comment, Task
from todo.utils import send_email_to_thread_participants, toggle_task_completed from todo.utils import send_email_to_thread_participants, toggle_task_completed, staff_check
@login_required @login_required
@user_passes_test(staff_check)
def task_detail(request, task_id: int) -> HttpResponse: def task_detail(request, task_id: int) -> HttpResponse:
"""View task details. Allow task details to be edited. Process new comments on task. """View task details. Allow task details to be edited. Process new comments on task.
""" """

View file

@ -1,5 +1,5 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@ -7,9 +7,11 @@ from django.urls import reverse
from todo.models import Task from todo.models import Task
from todo.utils import toggle_task_completed from todo.utils import toggle_task_completed
from todo.utils import staff_check
@login_required @login_required
@user_passes_test(staff_check)
def toggle_done(request, task_id: int) -> HttpResponse: def toggle_done(request, task_id: int) -> HttpResponse:
"""Toggle the completed status of a task from done to undone, or vice versa. """Toggle the completed status of a task from done to undone, or vice versa.
Redirect to the list from which the task came. Redirect to the list from which the task came.