diff --git a/news/390.feature b/news/390.feature new file mode 100644 index 00000000..95f0ac20 --- /dev/null +++ b/news/390.feature @@ -0,0 +1,2 @@ +move UI related code and template of sharing view from plone.app.workflow to plone.app.layout +[MrTango] diff --git a/news/3953.tests.1 b/news/3953.tests.1 new file mode 100644 index 00000000..cd1db7a5 --- /dev/null +++ b/news/3953.tests.1 @@ -0,0 +1,2 @@ +Add ``sharing_csrf.txt`` functional doctest, moved here from ``Products.CMFPlone`` together with the Classic-UI ``@@sharing`` view. +[MrTango] diff --git a/setup.py b/setup.py index a7424451..4b82ef69 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ "plone.app.dexterity", "plone.app.linkintegrity", "plone.app.relationfield", + "plone.app.workflow", "plone.app.uuid", "plone.app.viewletmanager >=1.2", "plone.base >=4.0.0a1", diff --git a/src/plone/app/layout/controlpanels/configure.zcml b/src/plone/app/layout/controlpanels/configure.zcml index 370d65c1..00d1d512 100644 --- a/src/plone/app/layout/controlpanels/configure.zcml +++ b/src/plone/app/layout/controlpanels/configure.zcml @@ -1,7 +1,6 @@ + + + + diff --git a/src/plone/app/layout/controlpanels/sharing.pt b/src/plone/app/layout/controlpanels/sharing.pt new file mode 100644 index 00000000..57d752b0 --- /dev/null +++ b/src/plone/app/layout/controlpanels/sharing.pt @@ -0,0 +1,292 @@ + + + + + + + + + +
+
+ + +

+ Sharing for + title +

+ +
+ You can control who can view and edit your item using the list below. +
+ +
+ +
+
+ + + +
+ + +
+
+
+ + +
+ + + + + + + + + + + + + + + + + +
Name
+ + + + + + + + + + + (${entry/id}) + + + + + + + + + + + + + + +
+
+ +
+
+ + + + By default, permissions from the container of this item are inherited. + If you disable this, only the explicitly defined sharing permissions will + be valid. + In the overview, the symbol + + indicates an inherited value. Similarly, the symbol + + indicates a global role, which is managed by the site administrator. + +
+
+ + + +
+
+
+
+
+ + + + diff --git a/src/plone/app/layout/controlpanels/sharing.py b/src/plone/app/layout/controlpanels/sharing.py new file mode 100644 index 00000000..23b3a847 --- /dev/null +++ b/src/plone/app/layout/controlpanels/sharing.py @@ -0,0 +1,75 @@ +from plone.app.workflow.browser.sharing import SharingView as ApiSharingView +from plone.app.workflow.events import LocalrolesModifiedEvent +from plone.base import PloneMessageFactory as _ +from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile +from Products.statusmessages.interfaces import IStatusMessage +from zExceptions import Forbidden +from zope.event import notify + + +class SharingView(ApiSharingView): + # Actions + template = ViewPageTemplateFile("sharing.pt") + + def __call__(self): + """Perform the update and redirect if necessary, or render the page""" + postback = self.handle_form() + if postback: + return self.template() + else: + context_state = self.context.restrictedTraverse("@@plone_context_state") + url = context_state.view_url() + self.request.response.redirect(url) + + def handle_form(self): + """ + We split this out so we can reuse this for ajax. + Will return a boolean if it was a post or not + """ + postback = True + + form = self.request.form + submitted = form.get("form.submitted", False) + save_button = form.get("form.button.Save", None) is not None + cancel_button = form.get("form.button.Cancel", None) is not None + if submitted and save_button and not cancel_button: + if not self.request.get("REQUEST_METHOD", "GET") == "POST": + raise Forbidden + + authenticator = self.context.restrictedTraverse("@@authenticator", None) + if not authenticator.verify(): + raise Forbidden + + # Update the acquire-roles setting + if self.can_edit_inherit(): + inherit = bool(form.get("inherit", False)) + reindex = self.update_inherit(inherit, reindex=False) + else: + reindex = False + + # Update settings for users and groups + entries = form.get("entries", []) + roles = [r["id"] for r in self.roles()] + settings = [] + for entry in entries: + settings.append( + dict( + id=entry["id"], + type=entry["type"], + roles=[r for r in roles if entry.get("role_%s" % r, False)], + ) + ) + if settings: + reindex = self.update_role_settings(settings, reindex=False) or reindex + if reindex: + self.context.reindexObjectSecurity() + notify(LocalrolesModifiedEvent(self.context, self.request)) + IStatusMessage(self.request).addStatusMessage( + _("Changes saved."), type="info" + ) + + # Other buttons return to the sharing page + if cancel_button: + postback = False + + return postback diff --git a/src/plone/app/layout/testing.py b/src/plone/app/layout/testing.py index 6101bb85..76bcfc2f 100644 --- a/src/plone/app/layout/testing.py +++ b/src/plone/app/layout/testing.py @@ -7,6 +7,7 @@ from plone.app.testing import TEST_USER_ID from plone.base.utils import unrestricted_construct_instance from plone.testing import zope +from Products.CMFCore.utils import getToolByName class PloneAppLayoutFixture(PloneSandboxLayer): @@ -26,6 +27,91 @@ def setUpPloneSite(self, portal): mtool.createMemberArea(TEST_USER_ID) if mtool.getMemberareaCreationFlag(): mtool.setMemberareaCreationFlag() + applyProfile(portal, "plone.app.contenttypes:default") + + acl_users = getToolByName(portal, "acl_users") + + acl_users.userFolderAddUser( + "manager", + "secret", + [ + "Manager", + ], + [], + ) + acl_users.userFolderAddUser( + "member", + "secret", + [ + "Member", + ], + [], + ) + acl_users.userFolderAddUser( + "owner", + "secret", + [ + "Owner", + ], + [], + ) + acl_users.userFolderAddUser( + "reviewer", + "secret", + [ + "Reviewer", + ], + [], + ) + acl_users.userFolderAddUser( + "editor", + "secret", + [ + "Editor", + ], + [], + ) + acl_users.userFolderAddUser( + "reader", + "secret", + [ + "Reader", + ], + [], + ) + + acl_users.userFolderAddUser( + "delegate_reader", + "secret", + [ + "Member", + ], + [], + ) + acl_users.userFolderAddUser( + "delegate_editor", + "secret", + [ + "Member", + ], + [], + ) + acl_users.userFolderAddUser( + "delegate_contributor", + "secret", + [ + "Member", + ], + [], + ) + acl_users.userFolderAddUser( + "delegate_reviewer", + "secret", + [ + "Member", + ], + [], + ) applyProfile(portal, "plone.app.layout:default") diff --git a/src/plone/app/layout/tests/sharing_csrf.txt b/src/plone/app/layout/tests/sharing_csrf.txt new file mode 100644 index 00000000..6344364d --- /dev/null +++ b/src/plone/app/layout/tests/sharing_csrf.txt @@ -0,0 +1,65 @@ +Sharing view CSRF protection +============================ + +The Classic-UI ``@@sharing`` view lives in plone.app.layout and is registered +on the ``IPloneAppLayoutLayer`` browser layer (installed by the +``plone.app.layout:default`` profile). Like the other write-enabled forms it +must be protected against CSRF attacks. This test used to live in +``Products.CMFPlone/tests/csrf.txt`` and moved here together with the view. + +First we need a logged in user with manager rights: + + >>> import transaction; transaction.commit() + >>> from plone.app.testing import SITE_OWNER_NAME + >>> from plone.app.testing import SITE_OWNER_PASSWORD + >>> from plone.testing.zope import Browser + >>> app = layer['app'] + >>> portal = layer['portal'] + >>> browser = Browser(app) + >>> browser.handleErrors = False + >>> browser.open('http://nohost/plone/login_form') + >>> browser.getControl(name='__ac_name').value = SITE_OWNER_NAME + >>> browser.getControl(name='__ac_password').value = SITE_OWNER_PASSWORD + >>> browser.getControl('Log in').click() + +Now we add a folder to share: + + >>> browser.open('http://nohost/plone/') + >>> browser.getLink(url='++add++Folder').click() + >>> browser.getControl('Title').value = 'a folder' + >>> browser.getControl('Save').click() + >>> browser.url + 'http://nohost/plone/a-folder/view' + +Reopen URL to clean up HTTP_REFERRER + + >>> browser.open('http://nohost/plone/a-folder/') + +"Sharing" the item via the toolbar link: + + >>> browser.getLink('Sharing').click() + >>> browser.url + 'http://nohost/plone/a-folder/@@sharing?_auth...' + >>> browser.getControl(name='entries.role_Editor:records').value + [] + +Change the value of the second _authenticator and check for Exception + + >>> browser.getControl(name='_authenticator', index=1).value = 'invalid!' + >>> browser.getControl(name='entries.role_Editor:records').value = ['True'] + >>> browser.getControl('Save').click() + Traceback (most recent call last): + ... + zExceptions.Forbidden + +With a valid authenticator the change goes through: + + >>> browser.getLink('Sharing').click() + >>> browser.getControl(name='entries.role_Editor:records').value = ['True'] + >>> browser.getControl('Save').click() + >>> browser.url + 'http://nohost/plone/a-folder/@@sharing' + >>> browser.contents + '...Info...Changes saved...' + >>> browser.getControl(name='entries.role_Editor:records').value + ['True'] diff --git a/src/plone/app/layout/tests/test_sharing_csrf.py b/src/plone/app/layout/tests/test_sharing_csrf.py new file mode 100644 index 00000000..99e66ca6 --- /dev/null +++ b/src/plone/app/layout/tests/test_sharing_csrf.py @@ -0,0 +1,22 @@ +from plone.app.layout.testing import PLONE_APP_LAYOUT_FUNCTIONAL_TESTING +from plone.testing import layered + +import doctest +import unittest + +OPTIONFLAGS = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest( + layered( + doctest.DocFileSuite( + "sharing_csrf.txt", + optionflags=OPTIONFLAGS, + package="plone.app.layout.tests", + ), + layer=PLONE_APP_LAYOUT_FUNCTIONAL_TESTING, + ) + ) + return suite diff --git a/src/plone/app/layout/tests/test_sharing_view.py b/src/plone/app/layout/tests/test_sharing_view.py new file mode 100644 index 00000000..4e64fa1e --- /dev/null +++ b/src/plone/app/layout/tests/test_sharing_view.py @@ -0,0 +1,58 @@ +from plone.app.layout.testing import PLONE_APP_LAYOUT_INTEGRATION_TESTING +from plone.app.testing import login +from zope.component import getMultiAdapter + +import unittest + + +class TestSharingView(unittest.TestCase): + layer = PLONE_APP_LAYOUT_INTEGRATION_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.request = self.layer["request"] + + self.portal.acl_users._doAddUser("testuser", "secret", ["Member"], []) + self.portal.acl_users._doAddUser("testreviewer", "secret", ["Reviewer"], []) + self.portal.acl_users._doAddUser("nonasciiuser", "secret", ["Member"], []) + self.portal.acl_users._doAddGroup( + "testgroup", [], title="Some meaningful title" + ) + testuser = self.portal.portal_membership.getMemberById("testuser") + testuser.setMemberProperties(dict(email="testuser@plone.org")) + nonasciiuser = self.portal.portal_membership.getMemberById("nonasciiuser") + nonasciiuser.setMemberProperties(dict(fullname="\xc4\xdc\xdf")) + login(self.portal, "manager") + + def test_group_name_links_to_prefs_for_admin(self): + """Make sure that for admins group name links to group prefs""" + self.request.form["search_term"] = "testgroup" + view = getMultiAdapter((self.portal, self.request), name="sharing") + self.assertIn( + '', + view(), + msg="Group name was not linked to group prefs.", + ) + + def test_group_name_links_not_include_authusers(self): + """Make sure that for admins group name links to group prefs""" + self.request.form["search_term"] = "testgroup" + view = getMultiAdapter((self.portal, self.request), name="sharing") + self.assertNotIn( + '', + view(), + msg="AuthenticatedUsers was linked to group prefs.", + ) + + def test_group_name_doesnt_link_to_prefs_for_reviewer(self): + """Make sure that for admins group name links to group prefs""" + login(self.portal, "testreviewer") + self.request.form["search_term"] = "testgroup" + view = getMultiAdapter((self.portal, self.request), name="sharing") + self.assertNotIn( + '', + view(), + msg="Group name link was unexpectedly shown to reviewer.", + )