Using membrane to provide membership behaviour
Since version 2.5, the user management infrastructure in Plone has been replaced by PAS, the Zope Pluggable Authentication Service, and PlonePAS, a Plone integration layer for this. PAS offers several advantages over plain user folders, mainly in terms of flexibility. Unfortunately, it is also more difficult to work with through-the-web and has a very decentralised API, based on the notion of plugin components, that can be difficult to understand at first.
Membrane (or rather, membrane with a lowercase m) is a component first developed by Plone Solutions and later improved by Rob Miller and others. It is similar to CMFMember in that it can turn content objects into users, although it is less concerned with replicating existing Plone functionality and more concerned with making a thin integration layer to plug into. It therefore fits b-org very well.
Membrane works on Archetypes objects (though theoretically it could be used with other objects as well). It adds a tool called membrane_tool which contains a registry of content types that are member- or group-sources, as well as a special catalog. Using the Archetypes catalog multiplex, it is able to catalog objects (which may also be cataloged in portal_catalog) and find them again based on various interfaces (that is, it catalogs the interfaces provided by an object). membrane provides a number of PAS plug-ins that will search this catalog when looking for users and delegate to the content objects (or rather, adapters on the content object) for obtaining user information, performing authentication and so on.
Registering with membrane
membrane_tool contains an API for registering content types as membership providers, but the easiest option is to use a GenericSetup profile (see the section on GenericSetup for the full story). In profiles/default/membrane_tool.xml, you will find:<?xml version="1.0"?>This registers the three content types (by their portal type), and specifies the workflow states in which they are "active" as member and group sources.
<object name="membrane_tool" meta_type="MembraneTool">
<membrane-type name="Department">
<active-workflow-state name="active" />
</membrane-type>
<membrane-type name="Employee">
<active-workflow-state name="active" />
</membrane-type>
<membrane-type name="Project">
<active-workflow-state name="published" />
<active-workflow-state name="private" />
</membrane-type>
</object>
Applying marker interfaces
When looking for content objects that provide group and member information, membrane will use a number of marker interfaces that indicate support for various types of behaviour. These are implemented by the three content type classes.In content/department.py, you will find:
from Products.membrane.interfaces import IPropertiesProviderAll this means is that the Department's schema is capable of providing properties to PAS. Properties (normally related to users, but groups can have properties as well) are just metadata about the user or group. Membrane supports as PAS properties plugin that will look for Archetypes schema fields with member_property=True set and report these back as user properties. Although Department does not use any such properties at the moment, we add this marker so that extension modules that use the schema extension mechanism can benefit from this.
...
class Department(ExtensibleSchemaSupport, BaseFolder):
"""A borg department.
Departments can contain other employees.
"""
implements(IDepartmentContent, IPropertiesProvider)
The equivalent setup for Employees, in content/employee.py, is a little more interesting.
from Products.membrane.interfaces import IUserAuthProviderHere, we are saying that:
from Products.membrane.interfaces import IPropertiesProvider
from Products.membrane.interfaces import IGroupsProvider
from Products.membrane.interfaces import IGroupAwareRolesProvider
...
class Employee(ExtensibleSchemaSupport, BaseContent):
"""A borg employee.
Employees are also users.
"""
implements(IEmployeeContent,
IUserAuthProvider,
IPropertiesProvider,
IGroupsProvider,
IGroupAwareRolesProvider,
IAttributeAnnotatable)
- An Employee can be used as a source of authentication (i.e. as a user), since it is marked with IUserAuthProvider. Note that the actual authentication is performed by a different adapter.
- An Employee can provide user properties to PAS via membrane, following IPropertiesProvider.
- An Employee can be part of a group, because of IGroupsProvider.
- An employee can be given roles. There is an IRolesProvider interface that we cold use for basic role awareness. The IGroupAwareRolesProvider is a sub-interface that will cause membrane to also look at the user's groups.
Projects does not require any particular marker interfaces.
Providing membership behaviour
When membrane looks for objects to provide membership-related behaviour, it will not only look for objects directly providing a particular interface, but also for objects that can be adapted to that interface. For example, the presence of the interface IGroup informs membrane that an object can act as a group, and contains methods that describe the members of that group.Of course, we could have declared that Department implemented IGroup and written these methods directly in the Department content object. Hopefully you'll agree now that this would not be optimal, since it mixes the content-object aspect and the group-behaviour aspect of Department into a single monolithic object. Instead, we will use an adapter, which also means that if you require different behaviour in an extension to b-org, you have only to override the adapter, leaving the core content object alone.
In membership/department.py, you will see:
class Group(object):Mostly, this is about examining the Department content object to find roles (which are listed in an Archetypes field, editable by the Manager role). When calculating roles, we make sure that we don't give roles if the Department group-source is actually disabled (by virtue of its workflow state and the settings in membrane_tool). The group title and id are taken from the object as well.
"""Allow departments to act as groups for contained employees
"""
implements(IGroup)
adapts(IDepartmentContent)
def __init__(self, context):
self.context = context
def Title(self):
return self.context.Title()
def getRoles(self):
"""Get roles for this department-group.
Return an empty list of roles if the department is in a workflow state
that is not active in membrane_tool.
"""
mb = getToolByName(self.context, MEMBRANE_TOOL)
wf = getToolByName(self.context, 'portal_workflow')
reviewState = wf.getInfoFor(self.context, 'review_state')
wfmapper = ICategoryMapper(mb)
categories = generateCategorySetIdForType(self.context.portal_type)
if wfmapper.isInCategory(categories, ACTIVE_STATUS_CATEGORY, reviewState):
return self.context.getRoles()
else:
return ()
def getGroupId(self):
return self.context.getId()
def getGroupMembers(self):
mt = getToolByName(self.context, MEMBRANE_TOOL)
usr = mt.unrestrictedSearchResults
members = {}
for m in usr(object_implements=IMembraneUserAuth.__identifier__,
path='/'.join(self.context.getPhysicalPath())):
members[m.getUserId] = 1
return tuple(members.keys())
The most interesting method is getGroupMembers(). Here, we perform a search in the membrane_tool catalog for objects adaptable to IMembraneUserAuth. This interface is the basic interface in membrane describing things that can act as users - there is an adapter from IUserAuthProvider to IMembraneUserAuth. We restrict this to objects inside the Department object. The net effect is that all Employee objects inside the Department are returned.
Now, let's say you had a need for a Department which in addition to acting as a group for all members inside it, also allowed some members from other departments to be in that group. In this case, you could use a schema extender to add a ReferenceField to the schema of Department that allowed the Department owner to reference other Employees. You would then provide an override adapter, perhaps subclassing Products.borg.membership.department.Group but overriding getGroupMembers() to append the ids of the referenced users as well as the contained ones ... or instead of, depending on your needs.
As it happens, Projects also act as groups, with members being assigned by reference, using two reference fields - one for project members, and one for project manangers. Here is the equivalent adapter from membership/project.py:
class Group(object):As may be expected, the membrane adapters for Employee are a bit more involved. They consist of the following:
"""Allow projects to be groups for related members and managers
"""
implements(IGroup)
adapts(IProjectContent)
def __init__(self, context):
self.context = context
def Title(self):
return self.context.Title()
def getRoles(self):
# The project does not imply any special roles *globally*, although
# the IWorkspace adapter above enables some local roles
return ()
def getGroupId(self):
return self.context.getId()
def getGroupMembers(self):
return [IUserRelated(m).getUserId() for m in
self.context.getRefs(PROJECT_RELATIONSHIP) +
self.context.getRefs(PROJECT_MANAGER_RELATIONSHIP)]
- IUserRelated adapter
- Provides a user id for employees. Note that user ids and user names are possibly different when PAS is used: the user id must be globally unique; the user name is the named used for logging in.
- IUserAuthentication adapter
- Used to perform actual authentication by validating a supplied username and password.
- IUserRoles adapter
- Used to determine which roles the particular user is given.
- IMembraneUserManagement
- Used by membrane and Plone's UI to deal with changes to the user, such as the adding of a new user (not implemented here, since we
The IUserRelated adapter is the simplest, as it simply invokes the user name. Note that by default, membrane will use the Archetypes UID() function as the user id. This is sensible, but unfortunately Plone's UI (and that of third party products) is not always aware of the distinction between user id and user name. Ideally, only the user name would ever be displayed, the user id being an internal concept, but in practice you may end up with things like member folder names that are long, unfriendly UID strings. Sometimes this may even be unavoidable in the general case, because it's possible that two different sources of users could use the same user name for two different user ids! For the purposes of b-org, however, we assume user names are unique and well-defined. The adapter is therefore quite trivial:
class UserRelated(object):The id of the content object that represents the employee is used as the user id. This is also used as the user name, as defined in the IUserAuthentication adapter:
"""Provide a user id for employees.
The user id will simply be the id of the member object. This overrides the
use of UIDs
"""
implements(IUserRelated)
adapts(IEmployeeContent)
def __init__(self, context):
self.context = context
def getUserId(self):
return self.context.getId()
class UserAuthentication(object):In the verifyCredentials() method, the adapter is passed the login and password as entered by the user in a dict (credentials) and then compares those to the values stored on its context (the Employee content object). The password is stored as a SHA1 digest in an annotation to make sure it cannot be read back by examining the content object - more on this in the section on annotations. Be aware also that the IUserAuthentication adapter is called on every request after a user is logged in and can deny access for whatever reason by returning non-True. This means that it is important that the method is as efficient as possible - expensive database lookups, for example, are probably not a good idea here!
"""Provide authentication against employees.
"""
implements(IUserAuthentication)
adapts(IEmployeeContent)
def __init__(self, context):
self.context = context
def getUserName(self):
return self.context.getId()
def verifyCredentials(self, credentials):
login = credentials.get('login', None)
password = credentials.get('password', None)
if login is None or password is None:
return False
digest = sha(password).digest()
annotations = IAnnotations(self.context)
passwordDigest = annotations.get(PASSWORD_KEY, None)
return (login == self.getUserName() and digest == passwordDigest)
The IUserRoles adapter is trivial. Roles are stored on the content object in a field that is editable only by managers. Of course, we could have picked roles from some other rule if necessary:
class UserRoles(object):The getRoles() method returns an iterable of strings representing applicable roles. Note that depending on group membership (and the IGroupAwareRolesProvider marker as described above) and local roles the user may in fact have more roles than what this method returns! The IUserRoles interface is concerned only with global roles intrinsic to the user.
"""Provide roles for employee users.
Roles may be set (by sufficiently privilged users) on the user object.
"""
implements(IUserRoles)
adapts(IEmployeeContent)
def __init__(self, context):
self.context = context
def getRoles(self):
return self.context.getRoles()
Finally, we have the IMembraneUserManagement adapter. This lets membrane know what to do when it is asked by Plone's UI to add, edit or remove users. In particular, the doChangeUser() method enables the PasswordResetTool to do its magic. Note that we have not implemented doAddUser(), because there is no well-defined global policy for where the actual Employee content object should be added! Recently membrane has gained some functionality whereby a site-local utility providing IUserAdder from membrane can be queried for this policy. That may be useful for b-org extension products, but b-org is still not in a position to make a general policy for this, so it is not implemented out of the box.
class UserManagement(object):
"""Provides methods for adding deleting and changing users
This is an implementation of IUserManagement from PlonePAS
"""
implements(IMembraneUserManagement)
adapts(IEmployeeContent)
def __init__(self, context):
self.context = context
def doAddUser(self, login, password):
"""This can't be done unless we have a canonical place to store users
some implementations may wish to define one and implement this.
"""
raise NotImplementedError
def doChangeUser(self, login, password, **kw):
self.context.setPassword(password)
if kw:
self.context.edit(**kw)
def doDeleteUser(self, login):
parent = aq_parent(aq_inner(self.context))
parent.manage_delObjects([self.context.getId()])
That's it! Through these adapters, the three b-org content types are able to act as sources of groups and users. Hopefully, you will appreciate the flexibility of the separation of concerns into adapters for things like editing user properties, determining user id, calculating roles and performing authentication. If you extend b-org, you can provide a more specific adapter to any of the above interfaces to customise the membership behaviour.