PAS plugins in Zope 2 - did they work?

Running Zope 2.10.6

I've added a PAS plugin. I copied/modified code from the DelegatingMultiPlugin.py file.
My logging shows that much, but not all of it is working.

Here's the code directly from DelegatingMultiPlugin.py

    security.declarePrivate('_getUserFolder')
    def _getUserFolder(self):
        """ Safely retrieve a User Folder to work with """
        uf = getattr(aq_base(self), 'acl_users', None)

        if uf is None and self.delegate:
            uf = self.unrestrictedTraverse(self.delegate)

        return uf


    security.declarePrivate('authenticateCredentials')
    def authenticateCredentials(self, credentials):
        """ Fulfill AuthenticationPlugin requirements """
        acl = self._getUserFolder()
        login = credentials.get('login', '')
        password = credentials.get('password', '')

        if not acl or not login or not password:
            return (None, None)

        if login == emergency_user.getUserName() and \
                AuthEncoding.pw_validate(emergency_user._getPassword(), password):
            return ( login, login )

        user = acl.getUser(login)

        if user is None:
            return (None, None)

        elif user and AuthEncoding.pw_validate(user._getPassword(),
                                               password):
            return ( user.getId(), login )

        return (None, None)

and here's the code from my plugin:

  security.declarePrivate('getUsers')
  def getUsers(self):
    """ Safely retrieve a User Folder to work with """
    uf = getattr(aq_base(self), 'acl_users', None)
    uf_str = '{aq_base}/acl_users'

    if uf is None and self.delegate:
      uf = self.unrestrictedTraverse(self.delegate)
      uf_str = self.delegate

    logger.debug('%s getUsers:uf: %s' % (_get_time(), uf_str))
    return uf


  security.declarePrivate('mapUser')
  def mapUser(self):
    """map the SAML user to a Zope User"""
    logger.debug('%s mapUser:enter' % (_get_time()))
    acl = self.getUsers()
    if not acl:
      logger.debug('%s mapUser: no valid acl_users found' % (_get_time()))
      return (None, None)

    userid = self.token.get('userid')
    login = self.token.get('login')
    user = acl.getUser(login)
    if user is not None:
      zu = (user.getId(), login)
      logger.debug('%s mapUser:zu: %s' % (_get_time(), zu))
      return (user.getId(), login)
    else:
      user = acl.getUserById(userid)
      if user is not None:
        zu = (userid, user.getUserName)
        logger.debug('%s mapUser:zu: %s' % (_get_time(), zu))
        return (userid, user.getUserName)

    return (None, None)

First of all, the getUsers function does not find the aq_base acl_users folder. It always returns the delegate folder.
Neither my acl.getUser or acl.getUserById function are returning with a user. My delegate folder absolutely has users matching my SAML users in self.token.
Another issue I am facing, my plugin only runs when I am navigating the ZMI. It does not run when I navigate the actual website.

Any help with Zope 2 PAS plugins will be appreciated.

This looks wrong: we likely are in a plugin context: no such context has an acl_users attribute (it might be "acquired", but aq_base will prevent this).

That's a consequence of the wrong _getUserFolder definition.
The usual user folder for a plugin p is given by aq_parent(aq_inner(p)) (i.e. the acquisition container of p).

Have you several acl_users? In this case, the acl_users are asked in reverse traversal order whether they can authenticate a user with the required roles. The first success yields the user and terminates the search. Thus, if an acl_users lower down in the hierarchy (later reached by the traversal) can authenticate a user with the required roles, higher up acl_users are not asked.

I'll take your word that the getUsers function is wrong. But, as I said, I copied it directly from one of the built-in plugins.
I'm not sure how to apply the function call you gave. I tried:

uf = getattr(aq_parent(aq_inner(self)), 'acl_users', None)

and it does not run.

Here I need to make sure we're on the same page with what up/down mean...
If I have the following tree:

/
/acl_users
/SamlTest
/SamlTest/acl_users

Is /SamlTest/acl_users "lower down" than /acl_users ?

from Acquisition import aq_inner, aq_parent
uf = aq_parent(aq_inner(self))

When you know that self is acquisition wrapped (true for normal use; may be wrong in test scenarios), uf = self.aq_inner.aq_parent will work, too.

/SamlTest is deeper in the hierarchy (aka "lower") then / (the hierarchy root).
When you traverse into /SamlTest, then /SamlTest/acl_users gets the first chance to authenticate the user with the required roles; only if this fails gets /acl_users its chance.

1 Like

PluggableAuthService has a nasty property: it does not report some types of exceptions and fails silently for them. This should prevent that parts of the object hierarchy become inaccessible in case of plugin errors -- ignoring the errors gives higher up user folders the chance to authenticate the user (e.g. a Zope Manager) and allow him to fix the problem. On this other hand, this makes it harder to analyze plugin bugs.

OK. Ignoring the traversal issue for the moment, I've got my code ostensibly working. But, the getUser and getUserById functions are not finding a user.

Is there some sort of security issue I have not learned about yet?

Here's my code, log, and screenshot proof the users exist:

  security.declarePrivate('getUserObjects')
  def getUserObjects(self):
    """Retrieve User Folder(s) to work with """
    uf_list = []
    uf = aq_parent(aq_inner(self))
    if uf is not None:
      uf_list.append(uf)

    if self.delegate:
      uf = self.unrestrictedTraverse(self.delegate)
      if uf is not None:
        uf_list.append(uf)

    logger.debug('%s getUserObjects:uf_list: %s' % (_get_time(), uf_list))
    return uf_list


  security.declarePrivate('mapUser')
  def mapUser(self):
    """map the SAML user to a Zope User"""
    logger.debug('%s mapUser:0' % (_get_time()))
    acl_list = self.getUserObjects()
    logger.debug('%s mapUser:1 %s' % (_get_time(), acl_list))
    if not acl_list or len(acl_list) == 0:
      logger.debug('%s mapUser: no valid acl_users found' % (_get_time()))
      return (None, None)

    userid = self.token.get('userid')
    logger.debug('%s mapUser:2 %s' % (_get_time(), userid))
    login = self.token.get('login')
    logger.debug('%s mapUser:3 %s' % (_get_time(), login))
    for acl in acl_list:
      logger.debug('%s mapUser: trying getUser(%s) from %s' % (_get_time(), login, [acl]))
      user = acl.getUser(login)
      if user is not None:
        break
    if user is not None:
      zu = (user.getId(), login)
      logger.debug('%s mapUser:zu: %s' % (_get_time(), zu))
      return (user.getId(), login)
    else:
      for acl in acl_list:
        logger.debug('%s mapUser: trying getUserById(%s) from %s' % (_get_time(), userid, [acl]))
        user = acl.getUserById(userid)
        if user is not None:
          break
      if user is not None:
        zu = (userid, user.getUserName)
        logger.debug('%s mapUser:zu: %s' % (_get_time(), zu))
        return (userid, user.getUserName)

    return (None, None)

my log:

2022-02-03T10:46:09 mapUser:0
2022-02-03T10:46:09 getUserObjects:uf_list: [<PluggableAuthService at /acl_users>, <PluggableAuthService at /SamlTest/acl_users>]
2022-02-03T10:46:09 mapUser:1 [<PluggableAuthService at /acl_users>, <PluggableAuthService at /SamlTest/acl_users>]
2022-02-03T10:46:09 mapUser:2 morty
2022-02-03T10:46:09 mapUser:3 msmith@samltest.id
2022-02-03T10:46:09 mapUser: trying getUser(msmith@samltest.id) from [<PluggableAuthService at /acl_users>]
2022-02-03T10:46:09 mapUser: trying getUser(msmith@samltest.id) from [<PluggableAuthService at /SamlTest/acl_users>]
2022-02-03T10:46:09 mapUser: trying getUserById(morty) from [<PluggableAuthService at /acl_users>]
2022-02-03T10:46:09 mapUser: trying getUserById(morty) from [<PluggableAuthService at /SamlTest/acl_users>]
2022-02-03T10:46:09 authenticateCredentials:zu: (None, None)

screenshot of Users:

Is that not natural?
You are using an external authentication. This means, that the users are remote, not local.

If you want to change this, you must add a local proxy user to a local user source when you first see the remote user.

Sorry, I don't understand "local proxy user to a local user source".

My flow is:

  1. user tries to access a URL configured to be authenticated by SAML (apache)
  2. SAML challenges (the SAML IDP)
  3. the SAML attributes get placed into HTTP header (apache)
  4. my plugin extracts the SAML attributes (in this case, userid and email)
  5. the values from the SAML "token" are being used to search for a Zope user in an existing acl_users folder

Unless I am misinterpreting the documentation at User Folder objects — Products.PluggableAuthService 2.7.1.dev0 documentation , I should be able to search a PluggableAuthService using a simple string.

You can search a user folder for the users known by its plugins (more precisely the plugins implementing IUserEnumarationPlugin). A priori, the user folder does not know remote users. You must do something to change that.

Thus, If you must be able to search for remote users, your user folder must have an activated IUserEnumerationPlugin which knows those users.

There are many possible options. One of them: Products.PlonePAS has a plugin properties which manages properties for a user and supports IUserEnumerationPlugin. You could integrate this plugin and make your SAML plugin interoperate with it (i.e. when a remote user comes to your site for the first time, add him to the property plugin).

OK. I had the User Manager object, but didn't realize I needed to "activate" it.
So, my plugin can now read from an acl_users folder.

I am 95% sure that my authenticateCredentials function is working properly. My logging statement clearly shows the correct (userid, login) tuple.

EDIT:
I had more to this post I've deleted as I think I solved it.
I'll know more about if I've solved my plugin issue after more "plumbing" work.

Christopher Biessener via Plone Community wrote at 2022-2-4 20:31 +0000:

...
I am 95% sure that my authenticateCredentials function is working properly. My logging statement clearly shows the correct (userid, login) tuple. And, if I navigate in the ZMI, I get the rendered web page instead of the manage interface for the object.

Is this behavior normal?

This can be normal: The ZMI automatically adapts to the current
user's permissions/roles. Should the user only have the
role "Authenticated", you may see only the object's rendering
and not the typical ZMI page.

Another plugin is responsible for the role (and thereby permission)
assignment.

How is the ZMI supposed to be used if objects get rendered instead of the manage interface being displayed?

The typical ZMI page shows a sequence of action tabs.
manage_workspace (that's the usual entry point to the ZMI)
displays the page corresponding to the first action for which
the current user has the required permission.
For many objects, there is the View action and this
renders the object WITHOUT the typical ZMI navigation.
Thus, if View is the first action accessible for the current user,
you will get an object rendering -- and not a ZMI page.

...
As a separate issue, that brings up the crazy way Zope handles URLs in the manage interface.
The SAML config wants to manage all URLs from a "base" location and deeper. So, any ZMI suffix is fair game to SAML.

Usually, Zope gives higher up user folders the chance to
authenticate a user with the required roles.
Therefore, a user authenticated as Manager by the top level
acl_users typically can use the ZMI also in parts
of the object hierarchy that is governed by a local user folder.

Of course, if the top level user folder can not yet
authenticate the user, then you may get the local user folders
challenge (SAML in your case) and this user folder may not be
able to assign the necessary roles/permissions.
In those cases, you must first authenticate with the high level
user folder and can then use the ZMI in the parts governed
by the local user folder.

I've tried to place a <LocationMatch> directive in my Apache config to turn SAML off for any "manage" URL, but the multitude of different ZMI URLs makes it difficult, if not impossible, to craft a regex to match all ZMI URLs. Simply clicking the /SamlTest folder in the ZMI will trigger the IDP challenge, even with my LocationMatch regex active.

What I wrote above applies to the Zope authentication logic only.

In your case, there is also logic in the web server and this
may require an SAML authentication even though Zope would not need
such an authentication (because the top level user folder can authenticate
the user).

To work around, you could use a role plugin to assign the
Manager role to some of your SAML users.

1 Like

Thank you for the detail on the ZMI.
I knew about the permissions, but somehow never made the connection with the weird behavior.
Things are making much more sense now.

I think I have all the pieces to the puzzle I need now. It's just some heads down work to mesh the saml/zope/app authentications amid some other plumbing.

There is also pas.plugins.headers which was created to pick up authenticated user headers and other attributes set by a frontend server like apache or nginx.

This plugin works well, but if you logout from the frontend server, you still have the Plone session active, thus you've also to logout locally in Plone. This happen if you want to have both frontend proxy logins and local logins. Still have to find a good solution, other then having a logout in Plone which also logout you from Plone login.

Is there a trick to getting a PAS plugin to work from any acl_users folder?
I'm ready to begin testing from the real acl_users, not just my SamlTest/acl_users. I've added the plugin and activated it, but from what I can tell, it's never getting called.

Of course, PAS plugins work in any PAS acl_users.

Note however, that you do not "activate a plugin" but "activate a plugin for an interface". Therefore, I assume that you do not have activated your plugin for all relevant interfaces (in the "other" acl_users).
Note further, that PAS swallows some exceptions; thus, "not working" does not necessarily mean "not called" but can also mean "has raised a (swallowed) exception".

Note that you should be able to use GenericSetup (--> portal_setup export/import) to "transfer" an acl_users configuration from one portal to another one (should you use CMFCore).