How to unit test security declarations in Plone and Zope

Security is hard. Unit testing security in Plone seems to be even harder. Here is a fool proof example how to do it. After comments I plan to release this as plone.org How to. I hope some of these ideas could get into PloneTestCase itself, so there wouldn’t be need to reinvent the wheel on every product.

Since 2004, when I was first introduced to Plone, it has been great mystery to me how to properly unit test your content type and workflow security declarations. Archetypes itself uses ugly hack where it creates secure Python Scripts from strings in Zope and then executes them. There had to be something better, but after asking questions no one seem to know what.

Function security declarations (security.declareProtected & co.) are only effective when Python is run in restricted mode. Entering to “restricted Zope Python” has not been very well documented anywhere, until RestrictedPython package Read me got revamped. This finally gave a clue how to one could hit Unauthorized exceptions in unit testing.

To enter the promised world of sandboxed Python you need to do following

  • Create a globals dictionary containing secured version of all __builtin__ functions and accessable objects
  • Compile your Python code through RestrictedPython compiler
  • Evaluate the result

Zope get_safe_globals() will overwrite __getattr__ with guarded_getattr, etc. providing automatic code execution level security. This information is not usable only for unit testing, but for scripting purposes also – it is a developer heaven to be able to give a sandboxed template environment to the users to play around withoutworry that they can escalate privileges.

But getting into restricted mode was not enough…  after that all kind of kinks started to hit me. Namely, in some places of Plone items are cached over the request lifecycle. Since unit tests do not create new requests, the cache will contain invalid values. Here borg.localroles bit me badly – I had to dig through the security management layers manually to see why the unit test code was giving bad results. Maybe it would be wise to have a flag for caches and disable them when running on a test layer?

Below is the my example code for normal Document content type and simple_publication_workflow. All sandboxed code are declared in independend functions, but it is easy to pass arguments for them. If there is no need to reuse the sandboxed functions, I recommend use Python lambda: function declaration.

Functions which should succesfully pass sandbox testing are evaluated using self.execUntrusted(). Functions which are expect to fail are evaluated using self.assertUnauthorized().

import unittest

# Zope security imports
from AccessControl import getSecurityManager
from AccessControl.SecurityManagement import newSecurityManager
from AccessControl.SecurityManagement import noSecurityManager
from AccessControl.SecurityManager import setSecurityPolicy
from AccessControl import ZopeGuards
from AccessControl.ZopeGuards import guarded_getattr, get_safe_globals
from AccessControl.ImplPython import ZopeSecurityPolicy
from AccessControl import Unauthorized

# Restricted Python imports
from RestrictedPython import compile_restricted
from RestrictedPython.Guards import safe_builtins
from RestrictedPython.SafeMapping import SafeMapping

from zope.component import getUtility, getMultiAdapter, getSiteManager
from Products.CMFCore.tests.base.security import UserWithRoles
from Products.CMFCore.WorkflowCore import WorkflowException
from Products.CMFCore.utils import getToolByName

__docformat__ = "epytext"
__author__ = "Mikko Ohtamaa <mikko@redinnovation.com>"
__license__ = "BSD"

class WorkflowTestCase(PloneTestCase):
    """ Test workflow access rights. """

    def afterSetUp(self):
        self.workflow = getToolByName(self.portal, 'portal_workflow')
        self.acl_users = getToolByName(self.portal, 'acl_users')
        self.types = getToolByName(self.portal, 'portal_types')
        self.registration =  getToolByName(self.portal, 'portal_registration')
        self.membership =  getToolByName(self.portal, 'portal_membership') 

        # Create a normal registered portal member
        # to be used in tests
        self.registration.addMember("testmember", "secret", ["Member",], properties={ 'username': "testmember", 'email' : "foobar@foobar.com" })

        # Set verbose security policy, making debugging Unauthorized
        # exceptions great deal easier in unit tests
        setSecurityPolicy(ZopeSecurityPolicy(verbose=True))

    def clearLocalRolesCache(self):
        """ Clear borg.localroles cache.

        borg.localroles check role implementation caches user/request combinations.
        If we edit the roles for a user we need to clear this cache,
        """
        from zope.annotation.interfaces import IAnnotations
        ann = IAnnotations(self.app.REQUEST)
        for key in ann.keys():
            del ann[key]

    def loginAsPortalMember(self, id):
        ''' Login as a normal portal member.

        @param id. username
        '''
        self.login(id)

    def _execUntrusted(self, debug, func, *args, **kwargs):
        """ Sets up a sandboxed Python environment with Zope security in place.

        Calls func() in an sandboxed environment. The security mechanism
        should catch all unauthorized function calls (declared
        with a class SecurityManager).

        Security is effective only inside the function itself -
        The function security declarations themselves are ignored.

        @param func: Function object
        @param args: Parameters delivered to func
        @param kwargs: Parameters delivered to func
        @param debug: If True, break into pdb debugger just before evaluation
        @return: Function return value
        """

        # Create global variable environment for the sandbox
        globals = get_safe_globals()
        globals['__builtins__'] = safe_builtins
        globals['_getattr_'] = guarded_getattr

        # Create variable context available in the restricted Python
        data = { "func" : func,
                "args" : ZopeGuards.SafeIter(args),
                "kwargs" : kwargs } # TODO: Do we need to map this to SafeMappings?

        globals.update(data)

        # Our magic code
        body = """func(*args, **kwargs)"""

        # The following will replace all function calls
        # in the code with Zope call guard proxies
        code = compile_restricted(body, "<string>", "eval")

        # Here is a good place to break in
        # if you need to do some ugly permission debugging
        if debug:
            import pdb
            pdb.set_trace()

        return eval(code, globals)

    def execUntrusted(self, func, *args, **kwargs):
        """ Sets up a sandboxed Python environment with Zope security in place. """
        return self._execUntrusted(False, func, *args, **kwargs)

    def execUntrustedDebug(self, func, *args, **kwargs):
        """ Sets up a sandboxed Python debug environment with Zope security in place. """
        return self._execUntrusted(True, func, *args, **kwargs)

    def assertUnauthorized(self, func, *args, **kwargs):
        """ Check that calling func with currently effective roles will raise Unauthroized error. """
        try:
            self.execUntrusted(func, *args, **kwargs)
        except Unauthorized, e:
            return

        raise AssertionError, 'Unauthorized exception was expected'

    def test_document_workflow_access(self):
        """ Check that anonymous users cannot access diagnosis in unwanted state. """

        def check_set_access(doc, text="foobar"):
            """ This is executed as RestrictedPython, print might not be available """

            # Try do a call which should hit Zope and Archetypes field security mechanisms
            doc.setText(text)

        def check_read_access(doc):
            """ This is executed as RestrictedPython, print might not be available """

            # Try do a call which should hit Zope and Archetypes field security mechanisms
            return doc.getText()

        def check_workflow_action(portal, action):
            """ Publish the document.

            Stresses secure workflow execution
            """
            portal.portal_workflow.doActionFor(portal.doc, action)

        # Login as a manager and create
        # an item which is initially private page to play around with
        self.loginAsPortalOwner()
        self.portal.invokeFactory("Document", "doc")
        doc = self.portal.doc
        # Item is private by default and editably by creator
        self.execUntrusted(check_set_access, doc)
        self.logout()

        # Anonymous cannot access the document when it's private
        self.assertUnauthorized(check_read_access, doc)
        self.assertUnauthorized(check_set_access, doc)       

        # Relogin as a normal member and see we cannot access the item
        self.loginAsPortalMember("testmember")
        self.assertUnauthorized(check_set_access, doc)
        self.logout()

        # Now relogin as the manager and share manager role with a member
        self.loginAsPortalOwner()
        self.membership.setLocalRoles(obj=doc,
                      member_ids=["testmember"],
                      member_role="Owner",
                      reindex=True)
        # IMPORTANT: This is a very invisible feature of Plone 3.1 -
        # setLocalRoles is ineffective in unit tests unless the cache is cleared
        self.clearLocalRolesCache()
        self.logout()

        # Relogin as a normal member and now we should be able to edit the document
        self.loginAsPortalMember("testmember")
        doc = self.portal.doc
        # Rich text is automatically paragraphed unless it
        # begins with HTML element
        self.assertEqual(self.execUntrusted(check_read_access, doc), "<p>foobar</p>")
        self.execUntrusted(check_set_access, doc)
        self.execUntrusted(check_workflow_action, self.portal, "submit")
        # Only site manager can publish items
        try:
            self.execUntrusted(check_workflow_action, self.portal, "publish")
            raise AssertionError("Publishing as normal member should not be possible")
        except WorkflowException:
            # WorkflowException: No workflow provides the '${action_id}' action.
            pass
        self.logout()           

        # Now the portal owner publishes the document
        self.loginAsPortalOwner()
        self.execUntrusted(check_workflow_action, self.portal, "publish")
        self.logout()

        # Anonymous should now have read access/no edit
        self.execUntrusted(check_set_access, doc)
        self.assertEqual(self.execUntrusted(check_read_access, doc), "<p>foobar</p>")      

        # Member should be still able to read and edit the document
        self.loginAsPortalMember("testmember")
        self.assertEqual(self.execUntrusted(check_read_access, doc), "<p>foobar</p>")
        self.logout()

def test_suite():
    suite = unittest.TestSuite()
    suite.addTest(unittest.makeSuite(WorkflowTestCase))
    return suite

Mysterious buildout error – missing docs/HISTORY.txt file

I was getting the following error with Plone buildout

Develop: '/home/moo/workspace/collective.easytemplate'
Traceback (most recent call last):
  File "/tmp/tmp_G8621", line 11, in ?
  File "/usr/lib/python2.4/site-packages/setuptools/command/easy_install.py", line 655, in install_eggs
    return self.build_and_install(setup_script, setup_base)
  File "/usr/lib/python2.4/site-packages/setuptools/command/easy_install.py", line 931, in build_and_install
    self.run_setup(setup_script, setup_base, args)
  File "/usr/lib/python2.4/site-packages/setuptools/command/easy_install.py", line 919, in run_setup
    run_setup(setup_script, args)
  File "/usr/lib/python2.4/site-packages/setuptools/sandbox.py", line 26, in run_setup
    DirectorySandbox(setup_dir).run(
  File "/usr/lib/python2.4/site-packages/setuptools/sandbox.py", line 63, in run
    return func()
  File "/usr/lib/python2.4/site-packages/setuptools/sandbox.py", line 29, in <lambda>
    {'__file__':setup_script, '__name__':'__main__'}
  File "setup.py", line 9, in ?
    return open(os.path.join(os.path.dirname(__file__), *rnames)).read()
  File "/usr/lib/python2.4/site-packages/setuptools/sandbox.py", line 166, in _open
    return _open(path,mode,*args,**kw)
IOError: [Errno 2] No such file or directory: 'docs/HISTORY.txt'
An internal error occured due to a bug in either zc.buildout or in a
recipe being used:
Traceback (most recent call last):
  File "/home/moo/workspace/Plone-3.1/eggs/zc.buildout-1.1.1-py2.4.egg/zc/buildout/buildout.py", line 1477, in main
    getattr(buildout, command)(args)
  File "/home/moo/workspace/Plone-3.1/eggs/zc.buildout-1.1.1-py2.4.egg/zc/buildout/buildout.py", line 324, in install
    installed_develop_eggs = self._develop()
  File "/home/moo/workspace/Plone-3.1/eggs/zc.buildout-1.1.1-py2.4.egg/zc/buildout/buildout.py", line 556, in _develop
    zc.buildout.easy_install.develop(setup, dest)
  File "/home/moo/workspace/Plone-3.1/eggs/zc.buildout-1.1.1-py2.4.egg/zc/buildout/easy_install.py", line 868, in develop
    assert os.spawnl(os.P_WAIT, executable, _safe_arg (executable), *args) == 0
AssertionError

My product had docs folder. HISTORY.txt was there properly. This made me scratch my head for a while.

Buildout calls easy_install as an external process. If easy_install eggs have dependencies in their setup.py easy_install tries to download and install these eggs.

There is no reported progress what eggs are installed in easy_install process created from buildout. Looks like buildout verbosity (-v) switch does not reach easy_install.

So the problem was not in my product, but in its dependency. However the debug output did not reveal that we were dealing with a dependency. Is there easy means to solve this kind of problems? I bluntly put debug prints inside my server wide setuptools Python files to known which was the faulty dependency.

It turned out that easy_install was trying to execute setup.py against a downloaded source distribution (.tar.gz). I had the same egg as a local source code copy. The source code contains docs folder, the egg doesn’t.

The solution was to change buildout.cfg develop directive to be the same as the flattened dependency order of the eggs (dependencies come top). This way setup.py was evaluated correctly against the source code folder.