Installing (and removing) a new PlonePAS plugin

Continuing the discussion from Keeping track of anonymous users sessions:

After reviewing the requirements I finally understood that what I need is a PlonePAS plugin to take care of the process.

I'm trying to understand how a PluggableAuthService works and it seems to be clear and simple, the only part I found a little bit confusing is how to install and uninstall the plugin itself.

I'm reviewing two plugins but both do things on a different ways:

(I'm specially interested in the second, because the process of authentication is similar on what I have and the usage of collective.beaker to deal with user session information seems important.)

pas.plugins.ldap does some things at the Zope level that are not present in cs.auth.twitter.

can someone explain the process of installing (and removing) a PlonePAS plugin?

I do not really get the question. We do not have a guide "how-to write a PAS plugins". This happens not that often.

So, I wrote a bunch of pas plugins already. My latest for a customer some weeks ago was a very customer specific user/groups implementation, but using post handler its the most modern approach. Getting all pieces together needs touches some files:

First you need the plugin with some installation glue:

def manage_addBdaUsersPlugin(context, id, title='', RESPONSE=None, **kw):
    """Create an instance of a BdaSaml2 Plugin.
    """
    plugin = BdaUsersPlugin(id, title, **kw)
    context._setObject(plugin.getId(), plugin)
    if RESPONSE is not None:
        RESPONSE.redirect('manage_workspace')


manage_addBdaUsersPluginForm = PageTemplateFile(
    'users_add_plugin.pt',
    globals(),
    __name__='addBdaUsersPlugin'
)

...

@implementer(
    IBdaUsersPlugin,
    pas_interfaces.IAuthenticationPlugin,
    pas_interfaces.IPropertiesPlugin,
    pas_interfaces.IUserEnumerationPlugin,
    pas_interfaces.IGroupsPlugin,
)
class BdaUsersPlugin(BasePlugin):
    """BdaSaml2 PAS Plugin
    """
    def __init__(self, id, title=None, **kw):
        self._setId(id)
        self.title = title
        self.plugin_caching = True    
...

And a page template to make it ttw installable (ZMI):

<h1 tal:replace="structure here/manage_page_header">Header</h1>

<h2> Add poi.pas.saml2 Users plugin</h2>

<p class="form-help">
Adds the poi.pas.saml2 User plugin.
</p>

<form action="manage_addPPS2UserPlugin" method="POST">
    <table>
        <tr>
           <td class="form-label"> Id </td>
           <td> <input type="text" name="id" /> </td>
        </tr>
        <tr>
           <td class="form-label"> Title </td>
           <td> <input type="text" name="title" /> </td>
        </tr>
        <tr>
           <td colspan="2">
            <div class="form-element">
             <input type="submit" value="submit"/>
             </div>
           </td>
        </tr>
    </table>
</form>
<h1 tal:replace="structure here/manage_page_footer">Footer</h1>

in your packages root configure.zcml do not forget to zope-initialize (unless you in Products.* namespace):

  <five:registerPackage
      initialize=".initialize"
      package="."
  />

And in the root __init__.py add the initialize code for the plugin:

# -*- coding: utf-8 -*-
"""Init and utils."""
from AccessControl.Permissions import add_user_folders
from bda.pas.saml2.plugins import users
from Products.PluggableAuthService import registerMultiPlugin
from zope.i18nmessageid import MessageFactory

import os

def initialize(context):
    """Initializer called when used as a Zope 2 product.
    """
    registerMultiPlugin(users.BdaUsersPlugin.meta_type)
    context.registerClass(
        users.BdaUsersPlugin,
        permission=add_user_folders,
        icon=os.path.join(os.path.dirname(__file__), 'plugins', 'users.png'),
        constructors=(
            users.manage_addBdaUsersPluginForm,
            users.manage_addBdaUsersPlugin
        ),
        visibility=None
    )

The GenericSetup post handler:

# -*- coding: utf-8 -*-
from bda.pas.saml2.interfaces import DEFAULT_ID_USERS
from bda.pas.saml2.plugins.users import BdaUsersPlugin
from Products.CMFPlone.interfaces import INonInstallable
from zope.component.hooks import getSite
from zope.interface import implementer


TITLE_USERS = 'CRM to Plone SAML2 plugin - Users (bda.pas.saml2)'


@implementer(INonInstallable)
class HiddenProfiles(object):

    def getNonInstallableProfiles(self):
        """Hide uninstall profile from site-creation and quickinstaller"""
        return [
            'bda.pas.saml2:uninstall',
        ]


def _add_plugin(pas, pluginid, title, pluginclass):
    if pluginid in pas.objectIds():
        return title + ' already installed.'
    plugin = pluginclass(pluginid, title=title)
    pas._setObject(pluginid, plugin)
    plugin = pas[plugin.getId()]  # get plugin acquisition wrapped!
    for info in pas.plugins.listPluginTypeInfo():
        interface = info['interface']
        if not interface.providedBy(plugin):
            continue
        pas.plugins.activatePlugin(interface, plugin.getId())
        pas.plugins.movePluginsDown(
            interface,
            [x[0] for x in pas.plugins.listPlugins(interface)[:-1]],
        )


def _remove_plugin(pas, pluginid):
    if pluginid in pas.objectIds():
        pas.manage_delObjects([pluginid])


def post_install(context):
    """Post install script"""
    aclu = getSite().acl_users
    _add_plugin(aclu, DEFAULT_ID_USERS, TITLE_USERS, BdaUsersPlugin)


def uninstall(context):
    """Uninstall script"""
    aclu = getSite().acl_users
    _remove_plugin(aclu, DEFAULT_ID_USERS)

and this is then registered as post_handler in install/uninstall GS-profiles:

  <genericsetup:registerProfile
      description="Installs the bda.pas.saml2 add-on."
      directory="profiles/default"
      name="default"
      post_handler=".setuphandlers.post_install"
      provides="Products.GenericSetup.interfaces.EXTENSION"
      title="bda.pas.saml2"
  />
  <genericsetup:registerProfile
      description="Uninstalls the bda.pas.saml2 add-on."
      directory="profiles/uninstall"
      name="uninstall"
      post_handler=".setuphandlers.uninstall"
      provides="Products.GenericSetup.interfaces.EXTENSION"
      title="bda.pas.saml2 (uninstall)"
  />
  <utility
      factory=".setuphandlers.HiddenProfiles"
      name="bda.pas.saml2-hiddenprofiles"
  />

Finally I test the setup itself.

# -*- coding: utf-8 -*-
from bda.pas.saml2.testing import BDAPASSAML2_ZOPE_FIXTURE

import unittest


class TestPluginForUsersCapability(unittest.TestCase):
    """interface plonepas_interfaces.capabilities.IGroupCapability

    Test if above interface works as expected
    """

    layer = BDAPASSAML2_ZOPE_FIXTURE

    def setUp(self):
        """Custom shared utility setup for tests."""
        self.aclu = self.layer['app'].acl_users

    def test_addplugin_users(self):
        PLUGINID = 'bdasaml2test'
        from bda.pas.saml2.setuphandlers import _add_plugin
        from bda.pas.saml2.plugins.users import BdaUsersPlugin
        result = _add_plugin(self.aclu, PLUGINID, 'Users', BdaUsersPlugin)
        self.assertIs(result, None)
        self.assertIn(PLUGINID, self.aclu.objectIds())

        bdasaml2 = self.aclu[PLUGINID]
        self.assertIsInstance(bdasaml2, BdaUsersPlugin)

        result = _add_plugin(self.aclu, PLUGINID, 'Users', BdaUsersPlugin)
        self.assertEqual(result, 'Users already installed.')

    def test_removeplugin_users(self):
        # add before remove
        PLUGINID = 'bdasaml2test'
        from bda.pas.saml2.setuphandlers import _add_plugin
        from bda.pas.saml2.plugins.users import BdaUsersPlugin
        _add_plugin(self.aclu, PLUGINID, 'Users', BdaUsersPlugin)
        self.assertIn(PLUGINID, self.aclu.objectIds())

        # now remove it
        from bda.pas.saml2.setuphandlers import _remove_plugin  # noqa
        _remove_plugin(self.aclu, pluginid=PLUGINID)

        self.assertNotIn(PLUGINID, self.aclu.objectIds())

for completeness here the testing.py:

# -*- coding: utf-8 -*-
from plone.app.contenttypes.testing import PLONE_APP_CONTENTTYPES_FIXTURE
from plone.app.robotframework.testing import REMOTE_LIBRARY_BUNDLE_FIXTURE
from plone.app.testing import applyProfile
from plone.app.testing import FunctionalTesting
from plone.app.testing import IntegrationTesting
from plone.app.testing import PloneSandboxLayer
from plone.protect import auto
from plone.testing import Layer
from plone.testing import z2
from Products.CMFCore.interfaces import ISiteRoot
from Products.PlonePAS.setuphandlers import migrate_root_uf
from zope.component import provideUtility

import bda.pas.saml2


ORIGINAL_CSRF_DISABLED = auto.CSRF_DISABLED


class BdaPasSaml2ZopeLayer(Layer):

    defaultBases = (
        z2.INTEGRATION_TESTING,
    )

    # Products that will be installed, plus options
    products = (
        ('Products.GenericSetup', {'loadZCML': True, 'product': True}, ),
        ('Products.CMFCore', {'loadZCML': True, 'product': True}, ),
        ('Products.PluggableAuthService', {
            'loadZCML': True, 'product': True},),
        ('Products.PluginRegistry', {'loadZCML': True, 'product': True}, ),
        ('Products.PlonePAS', {'loadZCML': True, 'product': True}, ),
        ('plone.behavior', {'loadZCML': True, 'product': False}, ),
        ('bda.pas.saml2', {'loadZCML': True, 'product': False}, ),
    )

    def setUp(self):
        self.setUpZCML()

    def testSetUp(self):
        self.setUpProducts()
        provideUtility(self['app'], provides=ISiteRoot)
        migrate_root_uf(self['app'])

    def setUpZCML(self):
        """Stack a new global registry and load ZCML configuration of Plone
        and the core set of add-on products into it.
        """
        # Load dependent products's ZCML
        from zope.configuration import xmlconfig
        from zope.dottedname.resolve import resolve

        import z3c.autoinclude
        xmlconfig.file(
            'meta.zcml',
            z3c.autoinclude,
            context=self['configurationContext']
        )

        def loadAll(filename):
            for p, config in self.products:
                if not config['loadZCML']:
                    continue
                try:
                    package = resolve(p)
                except ImportError:
                    continue
                try:
                    xmlconfig.file(
                        filename,
                        package,
                        context=self['configurationContext']
                    )
                except IOError:
                    pass

        loadAll('meta.zcml')
        loadAll('configure.zcml')
        loadAll('overrides.zcml')

    def setUpProducts(self):
        """Install all old-style products listed in the the ``products`` tuple
        of this class.
        """
        for prd, config in self.products:
            if config['product']:
                z2.installProduct(self['app'], prd)


BDAPASSAML2_ZOPE_FIXTURE = BdaPasSaml2ZopeLayer()


class BdaPasSaml2PloneLayer(PloneSandboxLayer):

    defaultBases = (PLONE_APP_CONTENTTYPES_FIXTURE,)

    def setUpZope(self, app, configurationContext):
        auto.CSRF_DISABLED = True
        self.loadZCML(package=bda.pas.saml2)
        z2.installProduct(app, 'bda.pas.saml2')

    def tearDownZope(self, app):
        auto.CSRF_DISABLED = ORIGINAL_CSRF_DISABLED

    def setUpPloneSite(self, portal):
        applyProfile(portal, 'bda.pas.saml2:default')


BDAPASSAML2_PLONE_FIXTURE = BdaPasSaml2PloneLayer()


BDAPASSAML2_PLONE_INTEGRATION_TESTING = IntegrationTesting(
    bases=(BDAPASSAML2_PLONE_FIXTURE,),
    name='BdaPasSaml2PloneLayer:IntegrationTesting'
)


BDAPASSAML2_PLONE_FUNCTIONAL_TESTING = FunctionalTesting(
    bases=(BDAPASSAML2_PLONE_FIXTURE,),
    name='BdaPasSaml2PloneLayer:FunctionalTesting'
)


BDAPASSAML2_PLONE_ACCEPTANCE_TESTING = FunctionalTesting(
    bases=(
        BDAPASSAML2_PLONE_FIXTURE,
        REMOTE_LIBRARY_BUNDLE_FIXTURE,
        z2.ZSERVER_FIXTURE
    ),
    name='BdaPasSaml2PloneLayer:AcceptanceTesting'
)

I hope that's it and I pasted all relevant parts.

HTH

5 Likes

thank you, very much for sharing this; I was able to implement the skeleton of my authentication plugin with some mocks in just a few hours; now I have some comments on your code above:

first, the old style Zope 2 initialization code seems not to be needed if you use the following directives on your configure.zcml file:

<configure
    ...
    xmlns:five="http://namespaces.zope.org/five"
    xmlns:pas="http://namespaces.zope.org/pluggableauthservice"
    ...
    >

  <include package="Products.CMFCore" file="permissions.zcml" />
  <include package="Products.PlonePAS" />

  <five:registerClass
      class=".plugin.SenhaWebUsers"
      meta_type="SenhaWebUsers"
      permission="zope2.ViewManagementScreens"
      addview="add-senhaweb-users-plugin"
      />

  <pas:registerMultiPlugin meta_type="SenhaWebUsers" />

  <browser:page
      name="add-senhaweb-users-plugin"
      for="zope.browser.interfaces.IAdding"
      class="pas.plugins.senhaweb.plugin.AddForm"
      permission="zope2.ViewManagementScreens"
      />

what would be the right permission there? I can't find the equivalent of Add User Folders.

second, the post installation/uninstallation scripts are awesome and I thank you for showing me the post_handler attribute of the directive; that's just awesome.

third, the testing setup is just way too complicated without reason; I have it working with a vanilla setup and no Zope 2 black magic at all.

the test was failing because I was looking for the plugin in the acl_users object in Zope's root and not in my Plone site; this is working now:

class InstallTestCase(unittest.TestCase):
    ...
    def test_pas_plugin(self):
        acl_users = self.portal.acl_users
        self.assertIn(PLUGIN_ID, acl_users.objectIds())


class UninstallTestCase(unittest.TestCase):
    ...
    def test_pas_plugin_removed(self):
        acl_users = self.portal.acl_users
        self.assertNotIn(PLUGIN_ID, acl_users.objectIds())

I'll release the code as an example as soon as I review that no internal information is compromised.