From 0cf82c27a75fb5826db231177d044ab1c8400c40 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 3 Aug 2021 15:07:17 +0200
Subject: [PATCH] general: add category-based management access
 (forms/cards/workflows) (#21991)

---
 tests/admin_pages/test_card.py             |  76 ++++++++++++
 tests/admin_pages/test_form.py             |  80 ++++++++++++
 tests/admin_pages/test_workflow.py         |  66 ++++++++++
 tests/conftest.py                          |  32 +++++
 wcs/admin/blocks.py                        |   4 +-
 wcs/admin/categories.py                    |  43 ++++++-
 wcs/admin/data_sources.py                  |   7 ++
 wcs/admin/forms.py                         | 118 +++++++++++++-----
 wcs/admin/mail_templates.py                |   2 +
 wcs/admin/workflows.py                     | 134 +++++++++++++++++----
 wcs/backoffice/cards.py                    |  27 ++---
 wcs/backoffice/management.py               |  29 +++--
 wcs/backoffice/root.py                     |  17 ++-
 wcs/carddef.py                             |   1 +
 wcs/categories.py                          |   4 +
 wcs/formdef.py                             |   1 +
 wcs/templates/wcs/backoffice/category.html |   9 +-
 17 files changed, 565 insertions(+), 85 deletions(-)

diff --git a/tests/admin_pages/test_card.py b/tests/admin_pages/test_card.py
index c309ff9f3..f24a3216d 100644
--- a/tests/admin_pages/test_card.py
+++ b/tests/admin_pages/test_card.py
@@ -1,6 +1,8 @@
 import re
+import xml.etree.ElementTree as ET
 
 import pytest
+from webtest import Upload
 
 from wcs import fields
 from wcs.admin.settings import UserFieldsFormDef
@@ -516,3 +518,77 @@ def test_carddef_usage(pub):
     user_formdef = UserFieldsFormDef(pub)
     user_formdef.fields = []
     user_formdef.store()
+
+
+def test_card_category_management_roles(pub, backoffice_user, backoffice_role):
+    app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
+    app.get('/backoffice/cards/', status=403)
+
+    CardDefCategory.wipe()
+    cat = CardDefCategory(name='Foo')
+    cat.store()
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'card title'
+    carddef.category_id = cat.id
+    carddef.fields = []
+    carddef.store()
+
+    cat = CardDefCategory(name='Bar')
+    cat.management_roles = [backoffice_role]
+    cat.store()
+
+    resp = app.get('/backoffice/cards/')
+    assert 'Foo' not in resp.text  # not a category managed by user
+    assert 'card title' not in resp.text  # carddef in that category
+    assert 'Bar' not in resp.text  # not yet any form in this category
+
+    resp = resp.click('New Card')
+    resp.forms[0]['name'] = 'card in category'
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+    resp = resp.forms[0].submit().follow()
+    new_carddef = CardDef.get_by_urlname('card-in-category')
+
+    # check category select only let choose one
+    resp = resp.click(href='/category')
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+
+    resp = app.get('/backoffice/cards/')
+    assert 'Bar' in resp.text  # now there's a form in this category
+    assert 'card in category' in resp.text
+
+    # no access to subdirectories
+    assert 'href="categories/"' not in resp.text
+    app.get('/backoffice/cards/categories/', status=403)
+
+    # no import into other category
+    carddef_xml = ET.tostring(carddef.export_to_xml(include_id=True))
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('carddef.wcs', carddef_xml)
+    resp = resp.forms[0].submit()
+    assert 'Invalid File (unauthorized category)' in resp.text
+
+    # check access to inspect page
+    carddef.workflow_roles = {'_viewer': str(backoffice_role.id)}
+    carddef.store()
+
+    carddata = carddef.data_class()()
+    carddata.just_created()
+    carddata.store()
+
+    resp = app.get(carddata.get_backoffice_url())
+    assert 'inspect' not in resp.text
+    resp = app.get(carddata.get_backoffice_url() + 'inspect', status=403)
+
+    new_carddef.workflow_roles = {'_viewer': str(backoffice_role.id)}
+    new_carddef.store()
+
+    carddata = new_carddef.data_class()()
+    carddata.just_created()
+    carddata.store()
+    resp = app.get(carddata.get_backoffice_url())
+    assert 'inspect' in resp.text
+    resp = app.get(carddata.get_backoffice_url() + 'inspect')
diff --git a/tests/admin_pages/test_form.py b/tests/admin_pages/test_form.py
index eaf7e6d9a..4398619ed 100644
--- a/tests/admin_pages/test_form.py
+++ b/tests/admin_pages/test_form.py
@@ -2611,3 +2611,83 @@ def test_form_new_computed_field(pub):
     assert FormDef.get(1).fields[0].key == 'computed'
     assert FormDef.get(1).fields[0].label == 'foobar'
     assert FormDef.get(1).fields[0].varname == 'foobar'
+
+
+def test_form_category_management_roles(pub, backoffice_user, backoffice_role):
+    app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
+    app.get('/backoffice/forms/', status=403)
+
+    Category.wipe()
+    cat = Category(name='Foo')
+    cat.store()
+
+    FormDef.wipe()
+    formdef = FormDef()
+    formdef.name = 'form title'
+    formdef.category_id = cat.id
+    formdef.fields = []
+    formdef.store()
+
+    cat = Category(name='Bar')
+    cat.management_roles = [backoffice_role]
+    cat.store()
+
+    resp = app.get('/backoffice/forms/')
+    assert 'Foo' not in resp.text  # not a category managed by user
+    assert 'form title' not in resp.text  # formdef in that category
+    assert 'Bar' not in resp.text  # not yet any form in this category
+
+    app.get('/backoffice/forms/%s/' % formdef.id, status=403)
+
+    resp = resp.click('New Form')
+    resp.forms[0]['name'] = 'form in category'
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+    resp = resp.forms[0].submit().follow()
+    new_formdef = FormDef.get_by_urlname('form-in-category')
+
+    # check category select only let choose one
+    resp = resp.click(href='/category')
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+
+    resp = app.get('/backoffice/forms/')
+    assert 'Bar' in resp.text  # now there's a form in this category
+    assert 'form in category' in resp.text
+
+    # no access to subdirectories
+    assert 'href="categories/"' not in resp.text
+    assert 'href="data-sources/"' not in resp.text
+    assert 'href="blocks/"' not in resp.text
+    app.get('/backoffice/forms/categories/', status=403)
+    app.get('/backoffice/forms/data-sources/', status=403)
+    app.get('/backoffice/forms/blocks/', status=403)
+
+    # no import into other category
+    formdef_xml = ET.tostring(formdef.export_to_xml(include_id=True))
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('formdef.wcs', formdef_xml)
+    resp = resp.forms[0].submit()
+    assert 'Invalid File (unauthorized category)' in resp.text
+
+    # check access to inspect page
+    formdef.workflow_roles = {'_receiver': int(backoffice_role.id)}
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+
+    resp = app.get(formdata.get_backoffice_url())
+    assert 'inspect' not in resp.text
+    resp = app.get(formdata.get_backoffice_url() + 'inspect', status=403)
+
+    new_formdef.workflow_roles = {'_receiver': int(backoffice_role.id)}
+    new_formdef.store()
+
+    formdata = new_formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+    resp = app.get(formdata.get_backoffice_url())
+    assert 'inspect' in resp.text
+    resp = app.get(formdata.get_backoffice_url() + 'inspect')
diff --git a/tests/admin_pages/test_workflow.py b/tests/admin_pages/test_workflow.py
index 13c863305..808f5fb06 100644
--- a/tests/admin_pages/test_workflow.py
+++ b/tests/admin_pages/test_workflow.py
@@ -1,6 +1,7 @@
 import io
 import os
 import re
+import xml.etree.ElementTree as ET
 from unittest import mock
 
 import pytest
@@ -2683,3 +2684,68 @@ def test_workflows_categories_in_index(pub):
     resp = app.get('/backoffice/workflows/')
     assert 'Uncategorised' in resp.text
     assert 'XcategoryY' in resp.text
+
+
+def test_workflow_category_management_roles(pub, backoffice_user, backoffice_role):
+    app = login(get_app(pub), username='backoffice-user', password='backoffice-user')
+    app.get('/backoffice/workflows/', status=403)
+
+    WorkflowCategory.wipe()
+    cat = WorkflowCategory(name='Foo')
+    cat.store()
+
+    Workflow.wipe()
+    workflow = Workflow()
+    workflow.name = 'workflow title'
+    workflow.category_id = cat.id
+    workflow.store()
+
+    cat = WorkflowCategory(name='Bar')
+    cat.management_roles = [backoffice_role]
+    cat.store()
+
+    resp = app.get('/backoffice/workflows/')
+    assert 'Foo' not in resp.text  # not a category managed by user
+    assert 'workflow title' not in resp.text  # workflow in that category
+    assert 'Bar' not in resp.text  # not yet any form in this category
+
+    resp = resp.click('New Workflow')
+    resp.forms[0]['name'] = 'workflow in category'
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+    resp = resp.forms[0].submit().follow()
+
+    # check category select only let choose one
+    resp = resp.click(href='category')
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    assert resp.forms[0]['category_id'].value == cat.id  # the category managed by user
+
+    resp = app.get('/backoffice/workflows/')
+    assert 'Bar' in resp.text  # now there's a form in this category
+    assert 'workflow in category' in resp.text
+
+    # no access to subdirectories
+    assert 'href="categories/"' not in resp.text
+    assert 'href="data-sources/"' not in resp.text
+    assert 'href="mail-templates/"' not in resp.text
+    app.get('/backoffice/workflows/categories/', status=403)
+    app.get('/backoffice/workflows/data-sources/', status=403)
+    app.get('/backoffice/workflows/mail-templates/', status=403)
+
+    # no import into other category
+    workflow_xml = ET.tostring(workflow.export_to_xml(include_id=True))
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('workflow.wcs', workflow_xml)
+    resp = resp.forms[0].submit()
+    assert 'Invalid File (unauthorized category)' in resp.text
+
+    # access to default workflows
+    app.get('/backoffice/workflows/_carddef_default/')
+    resp = app.get('/backoffice/workflows/_default/')
+
+    # duplicate on default workflows should open a dialog
+    resp = resp.click(href='duplicate')
+    assert len(resp.forms[0]['category_id'].options) == 1  # single option
+    resp = resp.forms[0].submit('cancel').follow()
+    resp = resp.click(href='duplicate')
+    resp = resp.forms[0].submit('submit').follow()
diff --git a/tests/conftest.py b/tests/conftest.py
index 47d579215..92652a9f5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -4,6 +4,8 @@ from unittest import mock
 
 import pytest
 
+from wcs.qommon.ident.password_accounts import PasswordAccount
+
 from .utilities import EmailsMocking, HttpRequestsMocking, SMSMocking
 
 
@@ -115,3 +117,33 @@ def sql_queries(monkeypatch):
     monkeypatch.setattr(psycopg2, 'connect', connect)
     yield queries
     wcs.sql.cleanup_connection()
+
+
+@pytest.fixture
+def backoffice_role(pub):
+    role = pub.role_class.get_on_index('backoffice-role', 'slug', ignore_errors=True)
+    if not role:
+        role = pub.role_class(name='backoffice role')
+        role.allows_backoffice_access = True
+        role.store()
+        assert role.slug == 'backoffice-role'
+    return role
+
+
+@pytest.fixture
+def backoffice_user(pub, backoffice_role):
+    try:
+        user = pub.user_class.get_users_with_email('backoffice-user@example.net')[0]
+    except IndexError:
+        user = pub.user_class()
+        user.name = 'backoffice user'
+        user.email = 'backoffice-user@example.net'
+        user.roles = [backoffice_role.id]
+        user.store()
+
+    account1 = PasswordAccount(id='backoffice-user')
+    account1.set_password('backoffice-user')
+    account1.user_id = user.id
+    account1.store()
+
+    return user
diff --git a/wcs/admin/blocks.py b/wcs/admin/blocks.py
index 22b5c3e99..88055f687 100644
--- a/wcs/admin/blocks.py
+++ b/wcs/admin/blocks.py
@@ -24,7 +24,7 @@ from wcs.backoffice.snapshots import SnapshotsDirectory
 from wcs.blocks import BlockDef, BlockdefImportError
 from wcs.qommon import _, misc, template
 from wcs.qommon.backoffice.menu import html_top
-from wcs.qommon.errors import TraversalError
+from wcs.qommon.errors import AccessForbiddenError, TraversalError
 from wcs.qommon.form import FileWidget, Form, HtmlWidget, StringWidget
 
 
@@ -195,6 +195,8 @@ class BlocksDirectory(Directory):
         self.section = section
 
     def _q_traverse(self, path):
+        if not get_publisher().get_backoffice_root().is_global_accessible('forms'):
+            raise AccessForbiddenError()
         get_response().breadcrumb.append(('blocks/', _('Fields Blocks')))
         return super()._q_traverse(path)
 
diff --git a/wcs/admin/categories.py b/wcs/admin/categories.py
index 7e21a6d2f..525e40772 100644
--- a/wcs/admin/categories.py
+++ b/wcs/admin/categories.py
@@ -23,17 +23,21 @@ from wcs.categories import CardDefCategory, Category, WorkflowCategory
 from wcs.formdef import FormDef
 from wcs.qommon import _, misc, template
 from wcs.qommon.backoffice.menu import html_top
+from wcs.qommon.errors import AccessForbiddenError
 from wcs.qommon.form import Form, HtmlWidget, SingleSelectWidget, StringWidget, WidgetList, WysiwygTextWidget
 from wcs.workflows import Workflow
 
 
-def get_categories(category_class):
-    t = sorted((misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select())
+def get_categories(category_class, filter_function):
+    t = sorted(
+        (misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select() if filter_function(x)
+    )
     return [x[1:] for x in t]
 
 
 class CategoryUI:
     category_class = Category
+    management_roles_hint_text = _('Roles allowed to create, edit and delete forms.')
 
     def __init__(self, category):
         self.category = category
@@ -66,6 +70,21 @@ class CategoryUI:
         if not new:
             # include permission fields
             roles = list(get_publisher().role_class.select(order_by='name'))
+            if 'management_roles' in [x[0] for x in self.category_class.XML_NODES]:
+                form.add(
+                    WidgetList,
+                    'management_roles',
+                    title=_('Management Roles'),
+                    element_type=SingleSelectWidget,
+                    value=self.category.management_roles,
+                    add_element_label=_('Add Role'),
+                    element_kwargs={
+                        'render_br': False,
+                        'options': [(None, '---', None)]
+                        + [(x, x.name, x.id) for x in roles if not x.is_internal()],
+                    },
+                    hint=self.management_roles_hint_text,
+                )
             if 'export_roles' in [x[0] for x in self.category_class.XML_NODES]:
                 form.add(
                     WidgetList,
@@ -79,7 +98,7 @@ class CategoryUI:
                         'options': [(None, '---', None)]
                         + [(x, x.name, x.id) for x in roles if not x.is_internal()],
                     },
-                    hint=_('Roles allowed to export data'),
+                    hint=_('Roles allowed to export data.'),
                 )
             if 'statistics_roles' in [x[0] for x in self.category_class.XML_NODES]:
                 form.add(
@@ -94,7 +113,7 @@ class CategoryUI:
                         'options': [(None, '---', None)]
                         + [(x, x.name, x.id) for x in roles if not x.is_internal()],
                     },
-                    hint=_('Roles with access to the statistics page'),
+                    hint=_('Roles with access to the statistics page.'),
                 )
 
         form.add_submit('submit', _('Submit'))
@@ -110,7 +129,13 @@ class CategoryUI:
             form.get_widget('name').set_error(_('This name is already used'))
             raise ValueError()
 
-        for attribute in ('description', 'redirect_url', 'export_roles', 'statistics_roles'):
+        for attribute in (
+            'description',
+            'redirect_url',
+            'management_roles',
+            'export_roles',
+            'statistics_roles',
+        ):
             widget = form.get_widget(attribute)
             if widget:
                 setattr(self.category, attribute, widget.parse())
@@ -120,10 +145,12 @@ class CategoryUI:
 
 class CardDefCategoryUI(CategoryUI):
     category_class = CardDefCategory
+    management_roles_hint_text = _('Roles allowed to create, edit and delete card models.')
 
 
 class WorkflowCategoryUI(CategoryUI):
     category_class = WorkflowCategory
+    management_roles_hint_text = _('Roles allowed to create, edit and delete workflows.')
 
 
 class CategoryPage(Directory):
@@ -234,6 +261,8 @@ class WorkflowCategoryPage(CategoryPage):
 
 class CategoriesDirectory(Directory):
     _q_exports = ['', 'new', 'update_order']
+
+    base_section = 'forms'
     category_class = Category
     category_ui_class = CategoryUI
     category_page_class = CategoryPage
@@ -301,11 +330,14 @@ class CategoriesDirectory(Directory):
         return self.category_page_class(component)
 
     def _q_traverse(self, path):
+        if not get_publisher().get_backoffice_root().is_global_accessible(self.base_section):
+            raise AccessForbiddenError()
         get_response().breadcrumb.append(('categories/', _('Categories')))
         return super()._q_traverse(path)
 
 
 class CardDefCategoriesDirectory(CategoriesDirectory):
+    base_section = 'cards'
     category_class = CardDefCategory
     category_ui_class = CardDefCategoryUI
     category_page_class = CardDefCategoryPage
@@ -313,6 +345,7 @@ class CardDefCategoriesDirectory(CategoriesDirectory):
 
 
 class WorkflowCategoriesDirectory(CategoriesDirectory):
+    base_section = 'workflows'
     category_class = WorkflowCategory
     category_ui_class = WorkflowCategoryUI
     category_page_class = WorkflowCategoryPage
diff --git a/wcs/admin/data_sources.py b/wcs/admin/data_sources.py
index 0b28d6961..91362125b 100644
--- a/wcs/admin/data_sources.py
+++ b/wcs/admin/data_sources.py
@@ -31,6 +31,7 @@ from wcs.data_sources import (
 from wcs.formdef import get_formdefs_of_all_kinds
 from wcs.qommon import _, errors, misc, template
 from wcs.qommon.backoffice.menu import html_top
+from wcs.qommon.errors import AccessForbiddenError
 from wcs.qommon.form import (
     CheckboxWidget,
     DurationWidget,
@@ -421,6 +422,12 @@ class NamedDataSourcesDirectory(Directory):
     ]
 
     def _q_traverse(self, path):
+        if (
+            not get_publisher().get_backoffice_root().is_global_accessible('forms')
+            and not get_publisher().get_backoffice_root().is_global_accessible('workflows')
+            and not get_publisher().get_backoffice_root().is_global_accessible('cards')
+        ):
+            raise AccessForbiddenError()
         get_response().breadcrumb.append(('data-sources/', _('Data Sources')))
         return super()._q_traverse(path)
 
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index 28b2a2a63..0b187e87a 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -32,7 +32,7 @@ from wcs.forms.root import qrcode
 from wcs.qommon import _, force_str, get_logger, misc, template
 from wcs.qommon.afterjobs import AfterJob
 from wcs.qommon.backoffice.menu import html_top
-from wcs.qommon.errors import TraversalError
+from wcs.qommon.errors import AccessForbiddenError, TraversalError
 from wcs.qommon.form import (
     CheckboxesWidget,
     CheckboxWidget,
@@ -62,15 +62,29 @@ from .fields import FieldDefPage, FieldsDirectory
 from .logged_errors import LoggedErrorsDirectory
 
 
+def is_global_accessible(section):
+    return get_publisher().get_backoffice_root().is_global_accessible(section)
+
+
 class FormDefUI:
     formdef_class = FormDef
     category_class = Category
+    section = 'forms'
 
     def __init__(self, formdef):
         self.formdef = formdef
 
     def get_categories(self):
-        return get_categories(self.category_class)
+        global_access = is_global_accessible(self.section)
+        user_roles = set(get_request().user.get_roles())
+
+        def filter_function(category):
+            if global_access:
+                return True
+            management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+            return bool(user_roles.intersection(management_roles))
+
+        return get_categories(self.category_class, filter_function=filter_function)
 
     @classmethod
     def get_workflows(cls, condition=lambda x: True):
@@ -87,12 +101,14 @@ class FormDefUI:
         form.add(StringWidget, 'name', title=_('Name'), required=True, size=40, value=formdef.name)
         categories = self.get_categories()
         if categories:
+            if is_global_accessible(self.section):
+                categories = [(None, '---', '')] + list(categories)
             form.add(
                 SingleSelectWidget,
                 'category_id',
                 title=_('Category'),
                 value=formdef.category_id,
-                options=[(None, '---', '')] + categories,
+                options=categories,
             )
         workflows = self.get_workflows()
         if len(workflows) > 1:
@@ -182,6 +198,8 @@ class AdminFieldsDirectory(FieldsDirectory):
 class OptionsDirectory(Directory):
     category_class = Category
     category_empty_choice = _('Select a category for this form')
+    section = 'forms'
+
     _q_exports = [
         'confirmation',
         'only_allow_one',
@@ -199,9 +217,10 @@ class OptionsDirectory(Directory):
         'user_support',
     ]
 
-    def __init__(self, formdef):
+    def __init__(self, formdef, formdefui):
         self.formdef = formdef
         self.changed = False
+        self.formdefui = formdefui
 
     def confirmation(self):
         form = Form(enctype='multipart/form-data')
@@ -331,7 +350,9 @@ class OptionsDirectory(Directory):
         return self.handle(form, _('Keywords'))
 
     def category(self):
-        categories = get_categories(self.category_class)
+        categories = self.formdefui.get_categories()
+        if is_global_accessible(self.section):
+            categories = [(None, '---', '')] + list(categories)
         form = Form(enctype='multipart/form-data')
         form.widgets.append(HtmlWidget('<p>%s</p>' % self.category_empty_choice))
         form.add(
@@ -339,7 +360,7 @@ class OptionsDirectory(Directory):
             'category_id',
             title=_('Category'),
             value=self.formdef.category_id,
-            options=[(None, '---', '')] + categories,
+            options=list(categories),
         )
         return self.handle(form, _('Category'))
 
@@ -572,6 +593,7 @@ class FormDefPage(Directory):
     formdef_export_prefix = 'form'
     formdef_ui_class = FormDefUI
     formdef_default_workflow = '_default'
+    section = 'forms'
     options_directory_class = OptionsDirectory
 
     delete_message = _('You are about to irrevocably delete this form.')
@@ -593,14 +615,14 @@ class FormDefPage(Directory):
         self.fields.html_top = self.html_top
         self.role = WorkflowRoleDirectory(self.formdef)
         self.role.html_top = self.html_top
-        self.options = self.options_directory_class(self.formdef)
+        self.options = self.options_directory_class(self.formdef, self.formdefui)
         self.logged_errors_dir = LoggedErrorsDirectory(
             parent_dir=self, formdef_class=self.formdef_class, formdef_id=self.formdef.id
         )
         self.snapshots_dir = SnapshotsDirectory(self.formdef)
 
     def html_top(self, title):
-        return html_top('forms', title)
+        return html_top(self.section, title)
 
     def add_option_line(self, link, label, current_value, popup=True):
         return htmltext(
@@ -1672,6 +1694,7 @@ class FormsDirectory(AccessControlled, Directory):
     formdef_page_class = FormDefPage
     formdef_ui_class = FormDefUI
 
+    section = 'forms'
     top_title = _('Forms')
     import_title = _('Import Form')
     import_submit_label = _('Import Form')
@@ -1687,23 +1710,43 @@ class FormsDirectory(AccessControlled, Directory):
     )
 
     def html_top(self, title):
-        return html_top('forms', title)
+        return html_top(self.section, title)
 
     def _q_traverse(self, path):
-        get_response().breadcrumb.append(('forms/', _('Forms')))
+        get_response().breadcrumb.append(('%s/' % self.section, self.top_title))
         return super()._q_traverse(path)
 
+    def is_accessible(self, user):
+        if is_global_accessible(self.section):
+            return True
+
+        # check for access to specific categories
+        user_roles = set(user.get_roles())
+        for category in self.category_class.select():
+            management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+            if management_roles and user_roles.intersection(management_roles):
+                return True
+
+        return False
+
     def _q_index(self):
         self.html_top(title=self.top_title)
         r = TemplateIO(html=True)
         get_response().add_javascript(['jquery.js', 'widget_list.js'])
         r += self.form_actions()
 
+        global_access = is_global_accessible(self.section)
+
         cats = self.category_class.select()
         self.category_class.sort_by_position(cats)
         one = False
         formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
         for c in cats:
+            if not global_access:
+                user_roles = set(get_request().user.get_roles())
+                management_roles = {x.id for x in getattr(c, 'management_roles') or []}
+                if not user_roles.intersection(management_roles):
+                    continue
             l2 = [x for x in formdefs if str(x.category_id) == str(c.id)]
             l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
                 x for x in l2 if x.disabled and not x.disabled_redirection
@@ -1712,16 +1755,17 @@ class FormsDirectory(AccessControlled, Directory):
                 r += self.form_list(l2, title=c.name)
                 one = True
 
-        l2 = [x for x in formdefs if not x.category]
-        if l2:
-            if one:
-                title = _('Misc')
-            else:
-                title = None
-            l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
-                x for x in l2 if x.disabled and not x.disabled_redirection
-            ]
-            r += self.form_list(l2, title=title)
+        if global_access:
+            l2 = [x for x in formdefs if not x.category]
+            if l2:
+                if one:
+                    title = _('Misc')
+                else:
+                    title = None
+                l2 = [x for x in l2 if not x.disabled or (x.disabled and x.disabled_redirection)] + [
+                    x for x in l2 if x.disabled and not x.disabled_redirection
+                ]
+                r += self.form_list(l2, title=title)
         return r.getvalue()
 
     def form_actions(self):
@@ -1731,11 +1775,12 @@ class FormsDirectory(AccessControlled, Directory):
         r += htmltext('<h2>%s</h2>') % _('Forms')
         if has_roles:
             r += htmltext('<span class="actions">')
-            r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
-            if get_publisher().has_site_option('fields-blocks'):
-                r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
-            if get_publisher().get_backoffice_root().is_accessible('categories'):
-                r += htmltext('<a href="categories/">%s</a>') % _('Categories')
+            if is_global_accessible('forms'):
+                r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
+                if get_publisher().has_site_option('fields-blocks'):
+                    r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
+                if get_publisher().get_backoffice_root().is_accessible('categories'):
+                    r += htmltext('<a href="categories/">%s</a>') % _('Categories')
             r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
             r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Form')
             r += htmltext('</span>')
@@ -1768,7 +1813,7 @@ class FormsDirectory(AccessControlled, Directory):
     def new(self):
         get_response().breadcrumb.append(('new', _('New')))
         if get_publisher().role_class.count() == 0:
-            return template.error_page('forms', _('You first have to define roles.'))
+            return template.error_page(self.section, _('You first have to define roles.'))
         formdefui = self.formdef_ui_class(None)
         form = formdefui.new_form_ui()
         if form.get_widget('cancel').parse():
@@ -1791,7 +1836,18 @@ class FormsDirectory(AccessControlled, Directory):
         return r.getvalue()
 
     def _q_lookup(self, component):
-        return self.formdef_page_class(component)
+        directory = self.formdef_page_class(component)
+        global_access = is_global_accessible(self.section)
+        if not global_access:
+            user_roles = set(get_request().user.get_roles())
+            management_roles = set()
+            if directory.formdef.category:
+                management_roles = {
+                    x.id for x in getattr(directory.formdef.category, 'management_roles') or []
+                }
+            if not management_roles.intersection(user_roles):
+                raise AccessForbiddenError()
+        return directory
 
     def p_import(self):
         form = Form(enctype='multipart/form-data')
@@ -1850,6 +1906,14 @@ class FormsDirectory(AccessControlled, Directory):
         except ValueError:
             error = True
 
+        global_access = is_global_accessible(self.section)
+        if not global_access:
+            management_roles = {x.id for x in getattr(formdef.category, 'management_roles', None) or []}
+            user_roles = set(get_request().user.get_roles())
+            if not user_roles.intersection(management_roles):
+                error = True
+                reason = _('unauthorized category')
+
         if error:
             if reason:
                 msg = _('Invalid File (%s)') % reason
diff --git a/wcs/admin/mail_templates.py b/wcs/admin/mail_templates.py
index 81c319eaa..a9f8bbc6b 100644
--- a/wcs/admin/mail_templates.py
+++ b/wcs/admin/mail_templates.py
@@ -40,6 +40,8 @@ class MailTemplatesDirectory(Directory):
     do_not_call_in_templates = True
 
     def _q_traverse(self, path):
+        if not get_publisher().get_backoffice_root().is_global_accessible('workflows'):
+            raise errors.AccessForbiddenError()
         get_response().breadcrumb.append(('mail-templates/', _('Mail Templates')))
         return super()._q_traverse(path)
 
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index 4227fd4bf..affae2f7d 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -66,6 +66,10 @@ from .logged_errors import LoggedErrorsDirectory
 from .mail_templates import MailTemplatesDirectory
 
 
+def is_global_accessible():
+    return get_publisher().get_backoffice_root().is_global_accessible('workflows')
+
+
 def svg(tag):
     return '{http://www.w3.org/2000/svg}%s' % tag
 
@@ -295,16 +299,30 @@ class WorkflowUI:
     def __init__(self, workflow):
         self.workflow = workflow
 
+    def get_categories(self):
+        global_access = is_global_accessible()
+        user_roles = set(get_request().user.get_roles())
+
+        def filter_function(category):
+            if global_access:
+                return True
+            management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+            return bool(user_roles.intersection(management_roles))
+
+        return get_categories(WorkflowCategory, filter_function=filter_function)
+
     def form_new(self):
         form = Form(enctype='multipart/form-data')
         form.add(StringWidget, 'name', title=_('Workflow Name'), required=True, size=30)
-        category_options = get_categories(WorkflowCategory)
+        category_options = self.get_categories()
         if category_options:
+            if is_global_accessible():
+                category_options = [(None, '---', '')] + list(category_options)
             form.add(
                 SingleSelectWidget,
                 'category_id',
                 title=_('Category'),
-                options=[(None, '---', '')] + category_options,
+                options=category_options,
             )
         form.add_submit('submit', _('Submit'))
         form.add_submit('cancel', _('Cancel'))
@@ -332,8 +350,10 @@ class WorkflowUI:
             form.get_widget('name').set_error(_('This name is already used'))
             raise ValueError()
 
-        for f in ('name',):
-            setattr(workflow, f, form.get_widget(f).parse())
+        for f in ('name', 'category_id'):
+            widget = form.get_widget(f)
+            if widget:
+                setattr(workflow, f, widget.parse())
         workflow.store()
         return workflow
 
@@ -1487,7 +1507,9 @@ class WorkflowPage(Directory):
         return html_top('workflows', title)
 
     def category(self):
-        categories = sorted((misc.simplify(x.name), x.id, x.name, x.id) for x in WorkflowCategory.select())
+        category_options = self.workflow_ui.get_categories()
+        if is_global_accessible():
+            category_options = [(None, '---', '')] + list(category_options)
 
         form = Form(enctype='multipart/form-data')
         form.widgets.append(HtmlWidget('<p>%s</p>' % _('Select a category for this workflow.')))
@@ -1496,7 +1518,7 @@ class WorkflowPage(Directory):
             'category_id',
             title=_('Category'),
             value=self.workflow.category_id,
-            options=[(None, '---', '')] + [x[1:] for x in categories],
+            options=category_options,
         )
 
         if not self.workflow.is_readonly():
@@ -1560,7 +1582,10 @@ class WorkflowPage(Directory):
         r += htmltext('<ul id="sidebar-actions">')
         if not self.workflow.is_readonly():
             r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete')
-        r += htmltext('<li><a href="duplicate">%s</a></li>') % _('Duplicate')
+        if not is_global_accessible() and self.workflow.id in ('_default', '_carddef_default'):
+            r += htmltext('<li><a rel="popup" href="duplicate">%s</a></li>') % _('Duplicate')
+        else:
+            r += htmltext('<li><a href="duplicate">%s</a></li>') % _('Duplicate')
         r += htmltext('<li><a href="export">%s</a></li>') % _('Export')
         if get_publisher().snapshot_class:
             r += htmltext('<li><a rel="popup" href="history/save">%s</a></li>') % _('Save snapshot')
@@ -1746,7 +1771,7 @@ class WorkflowPage(Directory):
 
         return redirect('.')
 
-    def edit(self, duplicate=False):
+    def edit(self):
         form = self.workflow_ui.form_edit()
         if form.get_widget('cancel').parse():
             return redirect('.')
@@ -1761,12 +1786,8 @@ class WorkflowPage(Directory):
 
         self.html_top(title=_('Edit Workflow'))
         r = TemplateIO(html=True)
-        if duplicate:
-            get_response().breadcrumb.append(('edit', _('Duplicate')))
-            r += htmltext('<h2>%s</h2>') % _('Duplicate Workflow')
-        else:
-            get_response().breadcrumb.append(('edit', _('Edit')))
-            r += htmltext('<h2>%s</h2>') % _('Edit Workflow')
+        get_response().breadcrumb.append(('edit', _('Edit')))
+        r += htmltext('<h2>%s</h2>') % _('Edit Workflow')
         r += form.render()
         return r.getvalue()
 
@@ -1801,6 +1822,31 @@ class WorkflowPage(Directory):
             return redirect('..')
 
     def duplicate(self):
+        if not is_global_accessible() and self.workflow.id in ('_default', '_carddef_default'):
+            category_options = self.workflow_ui.get_categories()
+            form = Form(enctype='multipart/form-data')
+            form.widgets.append(HtmlWidget('<p>%s</p>' % _('Select a category for this workflow.')))
+            form.add(
+                SingleSelectWidget,
+                'category_id',
+                title=_('Category'),
+                options=category_options,
+            )
+            form.add_submit('submit', _('Submit'))
+            form.add_submit('cancel', _('Cancel'))
+            if form.get_widget('cancel').parse():
+                return redirect('.')
+
+            if not form.is_submitted() or form.has_errors():
+                self.html_top(title=_('Duplicate Workflow'))
+                r = TemplateIO(html=True)
+                get_response().breadcrumb.append(('duplicate', _('Duplicate')))
+                r += htmltext('<h2>%s</h2>') % _('Duplicate Workflow')
+                r += form.render()
+                return r.getvalue()
+
+            self.workflow_ui.workflow.category_id = form.get_widget('category_id').parse()
+
         self.workflow_ui.workflow.id = None
         original_name = self.workflow_ui.workflow.name
         self.workflow_ui.workflow.name = '%s %s' % (self.workflow_ui.workflow.name, _('(copy)'))
@@ -1832,7 +1878,6 @@ class WorkflowsDirectory(Directory):
 
     data_sources = NamedDataSourcesDirectoryInWorkflows()
     mail_templates = MailTemplatesDirectory()
-    category_class = WorkflowCategory
     categories = WorkflowCategoriesDirectory()
 
     def html_top(self, title):
@@ -1842,6 +1887,19 @@ class WorkflowsDirectory(Directory):
         get_response().breadcrumb.append(('workflows/', _('Workflows')))
         return super()._q_traverse(path)
 
+    def is_accessible(self, user):
+        if is_global_accessible():
+            return True
+
+        # check for access to specific categories
+        user_roles = set(user.get_roles())
+        for category in WorkflowCategory.select():
+            management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+            if management_roles and user_roles.intersection(management_roles):
+                return True
+
+        return False
+
     def _q_index(self):
         self.html_top(title=_('Workflows'))
         r = TemplateIO(html=True)
@@ -1849,10 +1907,10 @@ class WorkflowsDirectory(Directory):
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % _('Workflows')
         r += htmltext('<span class="actions">')
-        if get_publisher().has_site_option('mail-templates'):
-            r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
-        r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
-        if get_publisher().get_backoffice_root().is_accessible('categories'):
+        if is_global_accessible():
+            if get_publisher().has_site_option('mail-templates'):
+                r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
+            r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
             r += htmltext('<a href="categories/">%s</a>') % _('Categories')
         r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
         r += htmltext('<a class="new-item" rel="popup" href="new">%s</a>') % _('New Workflow')
@@ -1889,7 +1947,17 @@ class WorkflowsDirectory(Directory):
             else:
                 unused_workflows.append(workflow)
 
-        categories = sorted(WorkflowCategory.select(), key=lambda x: misc.simplify(x.name))
+        if is_global_accessible():
+            categories = WorkflowCategory.select()
+        else:
+            categories = []
+            user_roles = set(get_request().user.get_roles())
+            for category in WorkflowCategory.select():
+                management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+                if management_roles and user_roles.intersection(management_roles):
+                    categories.append(category)
+
+        categories.sort(key=lambda x: misc.simplify(x.name))
 
         if categories:
             default_category = WorkflowCategory('Default')
@@ -1899,6 +1967,9 @@ class WorkflowsDirectory(Directory):
                     workflow.category_id = default_category.id
             categories = [default_category] + categories
 
+        if is_global_accessible():
+            categories = categories + [None]
+
         def workflow_section(r, workflows):
             r += htmltext('<ul class="objects-list single-links">')
             for workflow in workflows:
@@ -1924,7 +1995,7 @@ class WorkflowsDirectory(Directory):
                 r += htmltext('</li>')
             r += htmltext('</ul>')
 
-        for category in categories + [None]:
+        for category in categories:
             if category is None:
                 category_workflows = [x for x in workflows + unused_workflows if not x.category_id]
             else:
@@ -1969,7 +2040,18 @@ class WorkflowsDirectory(Directory):
         return r.getvalue()
 
     def _q_lookup(self, component):
-        return WorkflowPage(component)
+        directory = WorkflowPage(component)
+        global_access = is_global_accessible()
+        if directory.workflow.id not in ('_default', '_carddef_default') and not global_access:
+            user_roles = set(get_request().user.get_roles())
+            management_roles = set()
+            if directory.workflow.category:
+                management_roles = {
+                    x.id for x in getattr(directory.workflow.category, 'management_roles') or []
+                }
+            if not management_roles.intersection(user_roles):
+                raise errors.AccessForbiddenError()
+        return directory
 
     def p_import(self):
         form = Form(enctype='multipart/form-data')
@@ -2023,6 +2105,14 @@ class WorkflowsDirectory(Directory):
         except ValueError:
             error = True
 
+        global_access = is_global_accessible()
+        if not global_access:
+            management_roles = {x.id for x in getattr(workflow.category, 'management_roles', None) or []}
+            user_roles = set(get_request().user.get_roles())
+            if not user_roles.intersection(management_roles):
+                error = True
+                reason = _('unauthorized category')
+
         if error:
             if reason:
                 msg = _('Invalid File (%s)') % reason
diff --git a/wcs/backoffice/cards.py b/wcs/backoffice/cards.py
index fb38d4839..c2603c217 100644
--- a/wcs/backoffice/cards.py
+++ b/wcs/backoffice/cards.py
@@ -15,12 +15,11 @@
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
 from quixote import get_publisher, get_response, get_session, redirect
-from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
 from wcs.admin import utils
 from wcs.admin.categories import CardDefCategoriesDirectory
-from wcs.admin.forms import FormDefPage, FormDefUI, FormsDirectory, OptionsDirectory, html_top
+from wcs.admin.forms import FormDefPage, FormDefUI, FormsDirectory, OptionsDirectory
 from wcs.admin.logged_errors import LoggedErrorsDirectory
 from wcs.carddef import CardDef
 from wcs.categories import CardDefCategory
@@ -34,11 +33,13 @@ from ..qommon.storage import NotEqual, Null
 class CardDefUI(FormDefUI):
     formdef_class = CardDef
     category_class = CardDefCategory
+    section = 'cards'
 
 
 class CardDefOptionsDirectory(OptionsDirectory):
     category_class = CardDefCategory
     category_empty_choice = _('Select a category for this card model')
+    section = 'cards'
 
 
 class CardDefPage(FormDefPage):
@@ -46,6 +47,7 @@ class CardDefPage(FormDefPage):
     formdef_export_prefix = 'card'
     formdef_ui_class = CardDefUI
     formdef_default_workflow = '_carddef_default'
+    section = 'cards'
 
     options_directory_class = CardDefOptionsDirectory
 
@@ -59,9 +61,6 @@ class CardDefPage(FormDefPage):
         'Do note it kept its existing address and role and workflow parameters.'
     )
 
-    def html_top(self, title):
-        return html_top('cards', title)
-
     def _q_index(self):
         self.html_top(title=self.formdef.name)
         r = TemplateIO(html=True)
@@ -248,6 +247,7 @@ class CardsDirectory(FormsDirectory):
     formdef_page_class = CardDefPage
     formdef_ui_class = CardDefUI
 
+    section = 'cards'
     top_title = _('Card Models')
     import_title = _('Import Card Model')
     import_submit_label = _('Import Card Model')
@@ -261,22 +261,17 @@ class CardsDirectory(FormsDirectory):
         'you should nevertheless check everything is ok. '
     )
 
-    def html_top(self, title):
-        return html_top('cards', title)
-
-    def _q_traverse(self, path):
-        get_response().breadcrumb.append(('cards/', _('Card Models')))
-        return Directory._q_traverse(self, path)
-
     def form_actions(self):
         r = TemplateIO(html=True)
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % _('Card Models')
         r += htmltext('<span class="actions">')
-        r += htmltext('<a href="../forms/data-sources/">%s</a>') % _('Data sources')
-        if get_publisher().has_site_option('fields-blocks'):
-            r += htmltext('<a href="../forms/blocks/">%s</a>') % _('Fields blocks')
-        r += htmltext('<a href="categories/">%s</a>') % _('Categories')
+        if get_publisher().get_backoffice_root().is_global_accessible('forms'):
+            r += htmltext('<a href="../forms/data-sources/">%s</a>') % _('Data sources')
+            if get_publisher().has_site_option('fields-blocks'):
+                r += htmltext('<a href="../forms/blocks/">%s</a>') % _('Fields blocks')
+        if get_publisher().get_backoffice_root().is_global_accessible('cards'):
+            r += htmltext('<a href="categories/">%s</a>') % _('Categories')
         r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
         r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Card Model')
         r += htmltext('</span>')
diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py
index 3ed371328..5a58b4955 100644
--- a/wcs/backoffice/management.py
+++ b/wcs/backoffice/management.py
@@ -2905,6 +2905,25 @@ class FormBackOfficeStatusPage(FormStatusPage):
 
         return response
 
+    def can_go_in_inspector(self):
+        if get_publisher().get_backoffice_root().is_global_accessible('worflows'):
+            return True
+        if (
+            get_publisher()
+            .get_backoffice_root()
+            .is_global_accessible(self.formdata.formdef.backoffice_section)
+        ):
+            return True
+
+        user_roles = set(get_request().user.get_roles())
+        for category in (self.formdata.formdef.category, self.formdata.formdef.workflow.category):
+            if not category:
+                continue
+            management_roles = {x.id for x in getattr(category, 'management_roles') or []}
+            if user_roles.intersection(management_roles):
+                return True
+        return False
+
     def get_extra_context_bar(self, parent=None):
         formdata = self.filled
 
@@ -3014,10 +3033,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
                 '<div data-async-url="%suser-pending-forms"></div>' % formdata.get_url(backoffice=True)
             )
 
-        if not formdata.is_draft() and (
-            get_publisher().get_backoffice_root().is_accessible('forms')
-            or get_publisher().get_backoffice_root().is_accessible('workflows')
-        ):
+        if not formdata.is_draft() and self.can_go_in_inspector():
             r += htmltext('<div class="extra-context">')
             r += htmltext('<p><a href="%sinspect">' % formdata.get_url(backoffice=True))
             r += htmltext('%s</a></p>') % _('Data Inspector')
@@ -3393,10 +3409,7 @@ class FormBackOfficeStatusPage(FormStatusPage):
         return r.getvalue()
 
     def inspect(self):
-        if not (
-            get_publisher().get_backoffice_root().is_accessible('forms')
-            or get_publisher().get_backoffice_root().is_accessible('workflows')
-        ):
+        if not self.can_go_in_inspector():
             raise errors.AccessForbiddenError()
         charset = get_publisher().site_charset
         get_response().breadcrumb.append(('inspect', _('Data Inspector')))
diff --git a/wcs/backoffice/root.py b/wcs/backoffice/root.py
index 814c06413..767307519 100644
--- a/wcs/backoffice/root.py
+++ b/wcs/backoffice/root.py
@@ -98,21 +98,28 @@ class RootDirectory(BackofficeRootDirectory):
                 return subdirectory in ('settings', 'users')
             return False
 
+        # if the directory defines a is_accessible method, use it.
+        if hasattr(getattr(cls, subdirectory, None), 'is_accessible'):
+            return getattr(cls, subdirectory).is_accessible(get_request().user)
+
+        return cls.is_global_accessible(subdirectory)
+
+    @classmethod
+    def is_global_accessible(cls, subdirectory):
+        if cls.check_admin_for_all():
+            return True
         user_roles = set(get_request().user.get_roles())
         authorised_roles = set(get_cfg('admin-permissions', {}).get(subdirectory) or [])
         if authorised_roles:
             # access is governed by roles set in the settings panel
             return user_roles.intersection(authorised_roles)
 
-        # if the directory defines a is_accessible method, use it.
-        if hasattr(getattr(cls, subdirectory, None), 'is_accessible'):
-            return getattr(cls, subdirectory).is_accessible(get_request().user)
-
         # as a last resort, for the other directories, the user needs to be
         # marked as admin
         return get_request().user.can_go_in_admin()
 
-    def check_admin_for_all(self):
+    @classmethod
+    def check_admin_for_all(cls):
         admin_for_all_file_path = os.path.join(get_publisher().app_dir, 'ADMIN_FOR_ALL')
         if not os.path.exists(os.path.join(admin_for_all_file_path)):
             return False
diff --git a/wcs/carddef.py b/wcs/carddef.py
index a654875b9..512c68237 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -33,6 +33,7 @@ if not hasattr(types, 'ClassType'):
 
 class CardDef(FormDef):
     _names = 'carddefs'
+    backoffice_section = 'cards'
     data_sql_prefix = 'carddata'
     pickle_module_name = 'carddef'
     xml_root_node = 'carddef'
diff --git a/wcs/categories.py b/wcs/categories.py
index f12e854ef..cbc1db0cc 100644
--- a/wcs/categories.py
+++ b/wcs/categories.py
@@ -35,6 +35,7 @@ class Category(XmlStorableObject):
 
     export_roles = None
     statistics_roles = None
+    management_roles = None
 
     # declarations for serialization
     XML_NODES = [
@@ -45,6 +46,7 @@ class Category(XmlStorableObject):
         ('position', 'int'),
         ('export_roles', 'roles'),
         ('statistics_roles', 'roles'),
+        ('management_roles', 'roles'),
     ]
 
     def __init__(self, name=None):
@@ -149,6 +151,7 @@ class CardDefCategory(Category):
         ('description', 'str'),
         ('position', 'int'),
         ('export_roles', 'roles'),
+        ('management_roles', 'roles'),
     ]
 
     @classmethod
@@ -167,6 +170,7 @@ class WorkflowCategory(Category):
         ('name', 'str'),
         ('url_name', 'str'),
         ('description', 'str'),
+        ('management_roles', 'roles'),
     ]
 
     @classmethod
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 4acdde8f6..cff689c8c 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -85,6 +85,7 @@ class FormDef(StorableObject):
     data_sql_prefix = 'formdata'
     pickle_module_name = 'formdef'
     xml_root_node = 'formdef'
+    backoffice_section = 'forms'
     verbose_name = _('Form')
     verbose_name_plural = _('Forms')
 
diff --git a/wcs/templates/wcs/backoffice/category.html b/wcs/templates/wcs/backoffice/category.html
index 0f23b1e92..fbcf7984c 100644
--- a/wcs/templates/wcs/backoffice/category.html
+++ b/wcs/templates/wcs/backoffice/category.html
@@ -30,11 +30,18 @@
 {% endwith %}
 </div>
 
-{% if category.export_roles or category.statistics_roles %}
+{% if category.export_roles or category.statistics_roles or category.management_roles %}
 <div class="section">
 <h3>{% trans "Permissions" %}</h3>
 <div>
   <ul>
+    {% if category.management_roles %}
+    <li>{% trans "Management roles:" %}
+      <ul>
+      {% for role in category.management_roles %}<li>{{ role.name }}</li>{% endfor %}
+      </ul>
+    </li>
+    {% endif %}
     {% if category.export_roles %}
     <li>{% trans "Export roles:" %}
       <ul>
-- 
2.32.0

