# authentic2 - versatile identity manager # Copyright (C) 2010-2019 Entr'ouvert # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Affero General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import base64 import json import inspect import pickle from django.core import signing from django.core.exceptions import PermissionDenied, ValidationError from django.db import transaction from django.views.generic.base import ContextMixin from django.views.generic import (FormView, UpdateView, CreateView, DeleteView, TemplateView, DetailView, View) from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import FormMixin from django.http import HttpResponse, Http404 from django.utils.encoding import force_text from django.utils import six from django.utils.translation import ugettext_lazy as _ from django.utils.timezone import now from django.urls import reverse from django.urls import reverse_lazy from django.contrib.messages.views import SuccessMessageMixin from django.forms import MediaDefiningClass from django_tables2 import SingleTableView, SingleTableMixin from django_select2.views import AutoResponseView from gadjo.templatetags.gadjo import xstatic from django_rbac.utils import get_ou_model from authentic2.data_transfer import export_site, import_site, ImportContext from authentic2.forms.profile import modelform_factory from authentic2.utils import redirect, batch_queryset from authentic2.decorators import json as json_view from authentic2 import hooks from . import app_settings, utils, forms, widgets class MediaMixinBase(MediaDefiningClass, FormMixin): pass class MultipleOUMixin(object): '''Tell templates if there are multiple OU for adaptation in breadcrumbs for example''' def get_context_data(self, **kwargs): kwargs['multiple_ou'] = utils.get_ou_count() > 1 return super(MultipleOUMixin, self).get_context_data(**kwargs) @six.add_metaclass(MediaMixinBase) class MediaMixin(object): '''Expose needed CSS and JS files as a media object''' class Media: js = ( reverse_lazy('a2-manager-javascript-catalog'), xstatic('jquery.js', 'jquery.min.js'), xstatic('jquery-ui.js', 'jquery-ui.min.js'), 'js/gadjo.js', 'jquery/js/jquery.form.js', 'admin/js/urlify.js', 'authentic2/js/purl.js', 'authentic2/manager/js/manager.js', ) css = { 'all': ( 'authentic2/manager/css/style.css', ) } def get_context_data(self, **kwargs): kwargs['media'] = self.media ctx = super(MediaMixin, self).get_context_data(**kwargs) if 'form' in ctx: ctx['media'] += ctx['form'].media return ctx class PermissionMixin(object): '''Control access to views based on permissions''' permissions = None permissions_global = False def authorize(self, request, *args, **kwargs): if hasattr(self, 'model'): app_label = self.model._meta.app_label model_name = self.model._meta.model_name add_perm = '%s.add_%s' % (app_label, model_name) self.can_add = request.user.has_perm_any(add_perm) if hasattr(self, 'get_object') \ and ((hasattr(self, 'pk_url_kwarg') and self.pk_url_kwarg in self.kwargs) or (hasattr(self, 'slug_url_kwarg') and self.slug_url_kwarg in self.kwargs)): self.object = self.get_object() permissions = ('view', 'change', 'delete', 'manage_members') for permission in permissions: perm = '%s.%s_%s' % (app_label, permission, model_name) setattr(self, 'can_' + permission, request.user.has_perm(perm, self.object)) if self.permissions \ and not request.user.has_perms( self.permissions, self.object): raise PermissionDenied elif self.permissions \ and not request.user.has_perm_any(self.permissions): raise PermissionDenied else: if self.permissions: if self.permissions_global and not request.user.has_perms(self.permissions): raise PermissionDenied if not self.permissions_global and not request.user.has_perm_any(self.permissions): raise PermissionDenied def dispatch(self, request, *args, **kwargs): response = self.authorize(request, *args, **kwargs) if response is not None: return response return super(PermissionMixin, self).dispatch(request, *args, **kwargs) def filter_view(request, qs): model = qs.model perm = '%s.search_%s' % (model._meta.app_label, model._meta.model_name) return request.user.filter_by_perm(perm, qs) class FilterQuerysetByPermMixin(object): def get_queryset(self): qs = super(FilterQuerysetByPermMixin, self).get_queryset() return filter_view(self.request, qs) class FilterTableQuerysetByPermMixin(object): def get_table_data(self): qs = super(FilterTableQuerysetByPermMixin, self).get_table_data() if getattr(self, 'filter_table_by_perm', True): qs = filter_view(self.request, qs) return qs class TableQuerysetMixin(object): def get_table_queryset(self): return self.get_queryset() def get_table_data(self): return self.get_table_queryset() class SearchFormMixin(object): '''Handle a search form on the current table view. The search form class must implement a .filter(qs) method returning a new queryset.''' search_form_class = None def get_search_form_class(self): return self.search_form_class def get_search_form_kwargs(self): return {'request': self.request, 'data': self.request.GET} def get_search_form(self): form_class = self.get_search_form_class() if not form_class: return return form_class(**self.get_search_form_kwargs()) def dispatch(self, request, *args, **kwargs): self.search_form = self.get_search_form() return super(SearchFormMixin, self).dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super(SearchFormMixin, self).get_context_data(**kwargs) if self.search_form: ctx['search_form'] = self.search_form return ctx def filter_by_search(self, qs): if self.search_form and self.search_form.is_valid(): qs = self.search_form.filter(qs) return qs def get_table_data(self): qs = super(SearchFormMixin, self).get_table_data() qs = self.filter_by_search(qs) return qs class FormatsContextData(object): '''Export list of supported formats in context''' formats = ['csv'] def get_context_data(self, **kwargs): ctx = super(FormatsContextData, self).get_context_data(**kwargs) ctx['formats'] = self.formats return ctx class Action(object): '''Describe an action for view supporting multiples actions.''' name = None title = None confirm = None url_name = None url = None popup = True permission = None def __init__(self, name=None, title=None, confirm=None, url_name=None, url=None, popup=None, permission=None): if name is not None: self.name = name if title is not None: self.title = title if confirm is not None: self.confirm = confirm if url_name is not None: self.url_name = url_name if url is not None: self.url = url if popup is not None: self.popup = popup if permission is not None: self.permission = permission def display(self, instance, request): if self.permission: return request.user.has_perm(self.permission, instance) return True class AjaxFormViewMixin(object): '''Implement a JSON response for view which can be included in an AJAX popup''' success_url = '.' def dispatch(self, request, *args, **kwargs): response = super(AjaxFormViewMixin, self).dispatch(request, *args, **kwargs) return self.return_ajax_response(request, response) def return_ajax_response(self, request, response): if not request.is_ajax(): return response data = {} if 'Location' in response: location = response['Location'] # empty location means that the view can be used from anywhere # and so the redirect URL should not be used # otherwise compute an absolute URI from the relative URI if location and (not location.startswith('http://') or not location.startswith('https://') or not location.startswith('/')): location = request.build_absolute_uri(location) data['location'] = location if hasattr(response, 'render'): response.render() data['content'] = force_text(response.content) return HttpResponse(json.dumps(data), content_type='application/json') class TitleMixin(object): '''Mixin to provide a title to the view's template''' title = '' def get_context_data(self, **kwargs): ctx = super(TitleMixin, self).get_context_data(**kwargs) ctx['title'] = self.title ctx['manager_site_title'] = app_settings.SITE_TITLE return ctx class ActionMixin(object): '''Describe the main action implementd by a view''' action = None def get_context_data(self, **kwargs): ctx = super(ActionMixin, self).get_context_data(**kwargs) if self.action: ctx['action'] = self.action return ctx class OtherActionsMixin(object): '''Describe secondary actions possible on a view''' other_actions = None def get_context_data(self, **kwargs): ctx = super(OtherActionsMixin, self).get_context_data(**kwargs) ctx['other_actions'] = tuple(self.get_displayed_other_actions()) return ctx def get_other_actions(self): return self.other_actions or [] def get_displayed_other_actions(self): actions = [] other_actions = list(self.get_other_actions()) hooks.call_hooks('manager_modify_other_actions', self, other_actions) for action in other_actions: if callable(action.display) and not action.display(self.object, self.request): continue if action.display: actions.append(action) return actions def post(self, request, *args, **kwargs): self.object = self.get_object() for action in self.get_displayed_other_actions(): if action.name in request.POST: response = None if hasattr(action, 'do'): response = action.do(self, request, self.object) else: method = getattr(self, 'action_' + action.name, None) if method: response = method(request, *args, **kwargs) hooks.call_hooks('event', name='manager-action', user=self.request.user, action=action, instance=self.object) if response: return response self.request.method = 'GET' return self.get(request, *args, **kwargs) parent = super(OtherActionsMixin, self) if hasattr(parent, 'post'): return parent.post(request, *args, **kwargs) return self.get(request, *args, **kwargs) class ExportMixin(object): '''Help in implementd export views''' http_method_names = ['get', 'head', 'options'] export_prefix = '' def get_export_prefix(self): return self.export_prefix def get_resource(self): return self.resource_class() def get_data(self): qs = self.get_table_data() return batch_queryset(qs) def get_dataset(self): return self.get_resource().export(self.get_data()) def get(self, request, *args, **kwargs): export_format = kwargs['format'].lower() content_types = { 'csv': 'text/csv', } if export_format not in content_types: raise Http404('unknown format') content = getattr(self.get_dataset(), export_format) content_type = content_types[export_format] return self.export_response(content, content_type, export_format) def export_response(self, content, content_type, export_format): response = HttpResponse(content, content_type=content_type) filename = '%s%s.%s' % (self.get_export_prefix(), now().strftime('%Y%m%d_%H%M%S'), export_format) response['Content-Disposition'] = 'attachment; filename="%s"' \ % filename return response class FormNeedsRequest(object): def get_form_kwargs(self): kwargs = super(FormNeedsRequest, self).get_form_kwargs() if getattr(self.get_form_class(), 'need_request', False): kwargs['request'] = self.request return kwargs class ModelNameMixin(MediaMixin): '''Mixin to provide a model name to view's template''' def get_model_name(self): return self.model._meta.verbose_name def get_instance_name(self): if hasattr(self, 'get_object'): return six.text_type(self.get_object()) return u'' def get_context_data(self, **kwargs): ctx = super(ModelNameMixin, self).get_context_data(**kwargs) ctx['model_name'] = self.get_model_name() return ctx class TableHookMixin(object): '''Helper class for table views, hiding the OU column from tables if an OU filter exists''' def get_table(self, **kwargs): table = super(TableHookMixin, self).get_table(**kwargs) import copy table = copy.deepcopy(table) hooks.call_hooks('manager_modify_table', self, table) return table class BaseTableView(TitleMixin, TableHookMixin, FormatsContextData, ModelNameMixin, PermissionMixin, SearchFormMixin, FilterQuerysetByPermMixin, TableQuerysetMixin, SingleTableView): '''Base class for views showing a table of objects''' pass class SubTableViewMixin(TableHookMixin, FormatsContextData, ModelNameMixin, PermissionMixin, SearchFormMixin, FilterTableQuerysetByPermMixin, TableQuerysetMixin, SingleObjectMixin, SingleTableMixin, ContextMixin): '''Helper class for views showing a table of objects related to one object''' context_object_name = 'object' paginate_by = None class SimpleSubTableView(TitleMixin, SubTableViewMixin, TemplateView): '''Base class for views showing a simple table of objects related to one object''' pass class BaseSubTableView(MultipleOUMixin, TitleMixin, SubTableViewMixin, FormNeedsRequest, FormView): '''Base class for views showing a table of objects related to one object''' success_url = '.' class BaseDeleteView(TitleMixin, ModelNameMixin, PermissionMixin, AjaxFormViewMixin, DeleteView): '''Base class for views implementing deletion of an object''' template_name = 'authentic2/manager/delete.html' context_object_name = 'object' @property def permissions(self): app_label = self.model._meta.app_label model_name = self.model._meta.model_name return ['%s.delete_%s' % (app_label, model_name)] @property def title(self): return _('Delete %s') % self.get_instance_name() def get_success_url(self): return '../../' class ModelFormView(MediaMixin, FormNeedsRequest): '''Base class for views showing a form for a model''' fields = None form_class = None def get_fields(self): return self.fields def get_form_class(self): return modelform_factory(self.model, form=self.form_class, fields=self.get_fields()) def get_form(self, form_class=None): form = super(ModelFormView, self).get_form(form_class=form_class) hooks.call_hooks('manager_modify_form', self, form) return form class BaseDetailView(MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin, ModelFormView, DetailView): context_object_name = 'object' form_class = None @property def permissions(self): app_label = self.model._meta.app_label model_name = self.model._meta.model_name return ['%s.view_%s' % (app_label, model_name)] def get_form(self): form_class = self.get_form_class() if getattr(form_class, 'need_request', False): form = form_class(request=self.request, instance=self.object) else: form = form_class(instance=self.object) for field in form.fields.values(): widget = field.widget widget.attrs['disabled'] = '' if 'readonly' in widget.attrs: del widget.attrs['readonly'] return form def get_context_data(self, **kwargs): form = self.get_form() hooks.call_hooks('manager_modify_form', self, form) kwargs['form'] = form ctx = super(BaseDetailView, self).get_context_data(**kwargs) return ctx class BaseAddView(MultipleOUMixin, TitleMixin, ModelNameMixin, PermissionMixin, AjaxFormViewMixin, ModelFormView, CreateView): '''Base class for views for adding an instance of a model''' template_name = 'authentic2/manager/form.html' success_view_name = None context_object_name = 'object' @property def permissions(self): app_label = self.model._meta.app_label model_name = self.model._meta.model_name return ['%s.add_%s' % (app_label, model_name)] @property def title(self): return ('Add %s') % super(BaseAddView, self).get_model_name() def get_success_url(self): return reverse(self.success_view_name, kwargs={'pk': self.object.pk}) class BaseEditView(MultipleOUMixin, SuccessMessageMixin, TitleMixin, ModelNameMixin, PermissionMixin, AjaxFormViewMixin, ModelFormView, UpdateView): '''Base class for views for editing an instance of a model''' template_name = 'authentic2/manager/form.html' context_object_name = 'object' @property def permissions(self): app_label = self.model._meta.app_label model_name = self.model._meta.model_name return ['%s.change_%s' % (app_label, model_name)] @property def title(self): return _('Edit %s') % self.get_instance_name() def get_success_url(self): return '..' class HomepageView(TitleMixin, PermissionMixin, MediaMixin, TemplateView): template_name = 'authentic2/manager/homepage.html' permissions = ['a2_rbac.search_role', 'a2_rbac.search_organizationalunit', 'auth.search_group', 'custom_user.search_user'] default_entries = [ { 'class': 'icon-organizational-units', 'href': reverse_lazy('a2-manager-ous'), 'label': _('Organizational units'), 'order': -1, 'permission': 'a2_rbac.search_organizationalunit', 'slug': 'organizational-units', }, { 'class': 'icon-users', 'href': reverse_lazy('a2-manager-users'), 'label': _('Users'), 'order': -1, 'permission': 'custom_user.search_user', 'slug': 'users', }, { 'class': 'icon-roles', 'href': reverse_lazy('a2-manager-roles'), 'label': _('Roles'), 'order': -1, 'permission': 'a2_rbac.search_role', 'slug': 'roles', }, { 'class': 'icon-services', 'href': reverse_lazy('a2-manager-services'), 'label': _('Services'), 'order': -1, 'permission': 'authentic2.search_service', 'slug': 'services', }, ] def dispatch(self, request, *args, **kwargs): if app_settings.HOMEPAGE_URL: return redirect(request, app_settings.HOMEPAGE_URL) return super(HomepageView, self).dispatch(request, *args, **kwargs) def get_homepage_entries(self): entries = [] for entry in self.default_entries: if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']): continue entries.append(entry) for hook_entries in hooks.call_hooks('manager_homepage_entries', self): if not hasattr(hook_entries, 'append'): hook_entries = [hook_entries] for entry in hook_entries: if 'permission' in entry and not self.request.user.has_perm_any(entry['permission']): continue entries.append(entry) # use possible key order to sort # list.sort() is supposed to be a stable sort (already sorted entries # are kept in the same order) entries.sort(key=lambda d: d.get('order', 0)) return entries def get_context_data(self, **kwargs): kwargs['entries'] = self.get_homepage_entries() return super(HomepageView, self).get_context_data(**kwargs) homepage = HomepageView.as_view() class MenuJson(HomepageView): def get(self, request, *args, **kwargs): menu_entries = [] for entry in self.get_homepage_entries(): menu_entries.append({ 'label': six.text_type(entry['label']), 'slug': entry.get('slug', ''), 'url': request.build_absolute_uri(six.text_type(entry['href'])), }) return menu_entries menu_json = json_view(MenuJson.as_view()) class HideOUColumnMixin(object): '''Helper class for table views, hiding the OU column from tables if an OU filter exists''' def get_table(self, **kwargs): OU = get_ou_model() exclude_ou = False if (hasattr(self, 'search_form') and self.search_form.is_valid() and self.search_form.cleaned_data.get('ou') is not None): exclude_ou = True if OU.objects.count() < 2: exclude_ou = True if exclude_ou: exclude = kwargs.setdefault('exclude', []) if 'ou' not in exclude: exclude.append('ou') return super(HideOUColumnMixin, self).get_table(**kwargs) class Select2View(AutoResponseView): '''Overrided default django-select2 view to enforce security checks on Select2 AJAX requests.''' def get_widget_or_404(self): if not self.request.user.is_authenticated or not hasattr(self.request.user, 'filter_by_perm'): raise Http404('Invalid user') field_data = self.kwargs.get('field_id', self.request.GET.get('field_id', None)) try: field_data = signing.loads(field_data) except (signing.SignatureExpired, signing.BadSignature): raise Http404('Invalid or expired signature.') widget_class = field_data.get('class') if not widget_class or not hasattr(widgets, widget_class): raise Http404('Missing or unknown widget class.') widget = getattr(widgets, widget_class)() if not isinstance(widget, (widgets.SimpleModelSelect2Widget, widgets.SimpleModelSelect2MultipleWidget)): raise Http404('Reference to invalid widget class') qs = widget.get_queryset() qs.query.where = pickle.loads(base64.b64decode(field_data['where_clause'])) # check permissions again as current user may not be the one who obtained the field_id perm = '%s.search_%s' % (qs.model._meta.app_label, qs.model._meta.model_name) qs = self.request.user.filter_by_perm(perm, qs) widget.queryset = qs widget.view = self return widget select2 = Select2View.as_view() class SiteExport(View): def get(self, request, *args, **kwargs): if not request.user.is_superuser: raise PermissionDenied return HttpResponse( json.dumps(export_site(), indent=4), content_type='application/json') site_export = SiteExport.as_view() class SiteImportView(MediaMixin, TitleMixin, FormView): form_class = forms.SiteImportForm template_name = 'authentic2/manager/import_form.html' success_url = reverse_lazy('a2-manager-homepage') title = _('Site Import') def form_valid(self, form): try: with transaction.atomic(): import_site(form.cleaned_data['site_json'], ImportContext()) except ValidationError as e: form.add_error('site_json', e) return self.form_invalid(form) return super(SiteImportView, self).form_valid(form) def dispatch(self, request, *args, **kwargs): if not request.user.is_superuser: raise PermissionDenied return super(SiteImportView, self).dispatch(request, *args, **kwargs) site_import = SiteImportView.as_view()