May 16, 2021

Working with OpenLDAP Set ACLs

OpenLDAP is one of the most performant implementations of an LDAPv3 directory service. It was no-SQL before the term was coined and supports structuring data as a hierarchical tree of objects, which in turn can have attributes defined in a schema of object classes.

Access to the objects in the tree and the associated data can be controlled by OpenLDAP ACLs. They can refer to positions in the tree as well as to values of objects. Usually both approaches are exploited to craft ACLs that help implementing the constraints required by the data model of a specific LDAP object tree layout.

Set ACLs - why?

One of the most powerful features of LDAP ACLs are the so called Set ACLs, mainly documented in the OpenLDAP FAQ. One of the primary features is the possibility to recurse though the DN-valued uniqueMember attributes, which is one of the attributes commonly used to define group memberships. This is crucial for the evaluation of nested group memberships.

The documentation is a bit terse, so recently I had to dive a bit into research to find creative solutions for the requirements of a project. Maybe it helps others to note a few points on this journey.

Debugging ACLs

When designing OpenLDAP ACLs, the first tool that comes to your mind is slapacl. Nice tool, but currently it seems to have the limitation that it fails to evaluate attributes properly that are indexed. This was kind of surprising and I found it by debugging the ACL evaluation process. By debugging I mean experimenting iteratively and analyzing things on the level of log output. This can be activated by setting the loglevel to acl in slapd.conf (or directly via cn=config). Depending on the logging service used, it may be necessary to deactivate throttling (e.g. set $SystemLogRateLimitInterval to 0 in rsyslog.conf). Then add your ACL, check the operation with ldapadd/modify/delete and check the syslog e.g. for denied.

These are the kinds of lines you want to look out for:

=> access_allowed: add access to "<TARGET-DN>" "uniqueMember" requested

and

=> slap_access_allowed: add access denied by read(=rscxd)

Denied access is particularly easy to spot.

Set ACLs - how?

While tinkering with the Set ACLs I quickly discovered a simple rule of them, which is actually quite trivial but not obvious from the current documentation: When the set is non-empty, the ACL is applied. Simple. So you can write by set="[foo]" write and you have a security issue. Good to know. This points out, that the Set ACLs are a different animal compared to standard by <who> clauses: They match if the resulting set is non-empty.

My use case

The project I was working on involved restricting group memberships to organizational units. By customer request, the LDAP DIT was designed to provide several OU siblings, that should define their own namespace. Each OU could have their own admin and user accounts and groups to implement authorization constraints according to their needs. So you have these clear and simple sounding requirements:

Considering options

So, we are dealing with group memberships here. The IDM that operates the OpenLDAP uses uniqueMember and memberUid in parallel to define and enforce access control. So, to meet the requirements, we need ACLs that restrict the values of these multi valued attributes. Reading the OpenLDAP documentation, two options appear attractive:

  1. slapo-constraint
  2. OpenLDAP ACLs

Restrictions of slapo-constraint

OpenLDAP constraint_attribute rules look quite nice, but have a crucial shortcoming in that the <extra> : restrict=<uri> doesn’t support regex matching. Without that, I saw no way to define a correlation of LDAP DIT position and the value of an attribute.

It’s worthwhile to note though that constraint_attribute also supports the use of Set ACLs. So there is potential for future extensions here to make them significantly more powerful.

Handling nested groups

The object model of the project defines groups <OU>-read and <OU>-write that are solely used to authorize the corresponding operation on objects below the individual <OU>. These groups are located in a special administrative structure outside of the OUs, let’s call it cn=access-control here. These groups don’t directly contain users, but only other groups of users, which may be created at any time by the individual OU admins.

Now, let’s define a rule that <OU>-write can only put objects into the <OU>-read and <OU>-write groups that are positioned below cn=groups,ou=<OU>,<LDAP-BASE>. Other ACLs, which I skip here, restrict the child objects of this pattern of containers to be of some suitable objectClass like e.g. posixGroup. So we want to correlate the target DN of the LDAP operation with the ` statement of the ACL. Phrasing the ACL like this would be an option:

## All *-(read|write) groups must only contain groups from OUs
access to dn.regex="^cn=([^,]+)-(read|write),cn=access-control,cn=groups,<LDAP-BASE>$$" attrs=uniqueMember val.regex="^cn=.+,cn=groups,ou=([^,]+),<LDAP-BASE>$$"
   by set.expand="user & ([cn=$1-write,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" write
   by set.expand="user & ([cn=$1-read,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" read
   by * none

This makes use of the set.expand variant of the Set ACLs, which allows referring to regexp groups matched by the access to <what> statement. The $1 in the Set ACL literal ([...]) get expanded into the first matching regex group. The man page explains that $1 is the shorthand for ${d1}. When following ACL evaluation from the log output of OpenLDAP you can see that $0 probably matches the whole DN.

The trailing /uniqueMember is the syntax to dereference the object referred to in the Set ACL literal [...], take the values found in the uniqueMember attribute and the final asterisk asks OpenLDAP to iterate over nested group memberships.

Regex matching group membership values

Now, you may ask yourself, what about the regex match group in the val.regex (or value.regex) pattern? Yeah, that’s pretty useful and the man page states that it can be refereed to by ${v1}. Now, remember that admins of <OU> should only be allowed to authorize groups defined in the scope of their own OU? So we additionally need to correlate ${d1} (what target DN) with ${v1} (what attribute value). Correlation in Set ACLs is possible by the intersection operator &. The OpenLDAP FAQ shows a couple of instructive examples for this. Here we would need something like [${d1}] & [${v1}], which would evaluate to the value <OU> only of both regex groups match the same value. So we could get creative and do it like this:

## All *-(read|write) groups must only contain groups, specifically with DNs which belong to the repective OU
access to dn.regex="^cn=([^,]+)-(read|write),cn=access-control,cn=groups,<LDAP-BASE>$$" attrs=uniqueMember val.regex="^cn=.+,cn=groups,ou=([^,]+),<LDAP-BASE>$$"
   by set.expand="user & ([cn=]+[${d1}] & [${v1}]+[-write,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" write
   by set.expand="user & ([cn=]+[${d1}] & [${v1}]+[-read,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" read
   by * none

The Set ACL literal only evaluates to [cn=<OU>-write,cn=access-control,cn=groups,<LDAP-BASE>] if both, the DN and the uniqueMember attribute value, match. What if not? Good question. My first guess was that [${d1}] & [${v1}] evaluates to an empty string in that case. Fair enough for a “creative solution” (aka hack), if we take care that there will be no group with RDN cn=-write. But by carefully analyzing the OpenLDAP logs of the ACL evaluation process I found that OpenLDAP treats this as ACL set: empty, which can be compared to None in Python. And, as it happens, OpenLDAP treats the set [foo] + <empty> as <empty> set. This is pretty sweet, because it makes the ACL above more safe to use.

Exploiting Set ACLs for values

The approach above makes use of the specific naming scheme. But what about if we would also want to grant the Domain Admins group write access? After aimlessly pondering about this while gardening, I came to the conclusion that this mechanism can be exploited as - what I would call - a Set ACL gadget:

## Example for a Set ACL gadget that implements an if clause
access to dn.regex="^cn=([^,]+)-(read|write),cn=access-control,cn=groups,<LDAP-BASE>$$" attrs=uniqueMember val.regex="^cn=.+,cn=groups,ou=([^,]+),<LDAP-BASE>$$"
   by set.expand="user & ([cn=]+[${d1}] & [${v1}]+[-write,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" write
   by set.expand="user & ([cn=]+[${d1}] & [${v1}]+[-read,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" read
   by set.expand="user & ([cn=Domain Admins,cn=groups,<LDAP-BASE>])/uniqueMember* + ([x=${d1}] & [x=${v1}])/-1" write
   by * none

This makes use of the /-1 dereferencing operator that splits a given DN string according to the rules of the str2dn operation and returns the parent of the DN. As it turns out, to OpenLDAP the parent of x=<value> is an empty string and the parent of <empty set> is <empty set>. So in Python pseudo code this would be:

if ("${d1}" == "${v1}"):
    return "x=${d1},".split(',')[1:]
else:
    return None

Please note that this additional ACL statement is a tad more dangerous than the version above, as it totally relies on an undocumented behavior of OpenLDAP. If the OpenLDAP project at some point decides to change the behavior to result in an empty string instead on <empty set>, the constraining effect of the gadget would break. If you want to make use of this in production, you absolutely must properly secure it by CI test cases.

Next challenge: memberUid

Now, doing the same for memberUid provides another level of challenge: The attribute value doesn’t even refer to the OU context where the object is positioned. For this I also needed to employ the “Set ACL gadget”, which either returns an empty string or <empty set>.

First ansatz:

## The memberUids must only contain users and groups that exist in the corresponding OU
access to dn.regex="^cn=([^,]+)-(read|write),cn=access-control,cn=groups,<LDAP-BASE>$$" attrs=memberUid val.regex="(.+)"
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([ldap:///ou=$1,@%@ldap/base@%@??sub?(&(cn=${v1})(objectClass=posixGroup))]/entryDN/-* & []))/uniqueMember*" write
   by set.expand="user & [cn=$1-read,cn=access-control,cn=groups,<LDAP-BASE>]/uniqueMember*" read
   by * none

Nice! But after the first enthusiasm settled I discovered, that this denies removing values from memberUid that don’t have a corresponding object in the LDAP tree any longer. E.g. if you remove the object before removing its membership from the groups. Sure, that’s best practice anyway, and you would want to ensure referential integrity, but for the fun of it, let’s find a solution for this also in terms of Set ACLs.

As it turns out, we can only mitigate this issue, but not in an entirely satisfactory way. This problem simply is, that you cannot find out where a memberUid came from, once the referenced object is gone. So The only thing you can do, is to allow removing unknown memberUids.

## The memberUids in general (exceptions above) must only contain users and groups that exist in the corresponding OU
access to dn.regex="^cn=([^,]+)-(read|write),cn=access-control,cn=groups,<LDAP-BASE>$$" attrs=memberUid val.regex="(.+)"
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([ldap:///ou=$1,@%@ldap/base@%@??sub?(&(cn=${v1})(objectClass=posixGroup))]/entryDN/-* & []))/uniqueMember*" write
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([ldap:///@%@ldap/base@%@??sub?(&(cn=${v1})(objectClass=posixGroup))]/entryDN/-* & []))/uniqueMember*" read
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([x=]+this/memberUid & [x=${v1}])/-1)/uniqueMember*" delete
   by set.expand="user & [cn=$1-read,cn=access-control,cn=groups,<LDAP-BASE>]/uniqueMember*" read
   by * none

Here we make use of the special recursive dereferencing operator /-* documented in the OpenLDAP FAQ on Set ACLs.

Agreed, this ACL for uniqueUid is a bit tricky and it also fully relies of the “Set ACL gadget” trick. But in combination with the above ACLs for uniqueMember it may add additional protection. In UCS for example the IDM logic layer always writes memberUid only in combination with ‘uniqueMember` in the same LDAP operation, keeping both coherent. But as mentioned above, it’s vital to safeguard the OpenLDAP ACLs by specific CI tests.

Rinse and repeat

The same general techniques can be applied to implement the restrictions with respect to group memberships in the OUs themselves. No surprises here:

### Groups must only contain user accounts with DNs that belong to the repective OU
access to dn.regex="^.+,cn=groups,ou=([^,]+),<LDAP-BASE>$$" attrs=uniqueMember val.regex="^.+,cn=users,ou=([^,]+),@%@ldap/base@%@$$"
   by set.expand="user & ([cn=]+([$1] & [${v2}])+[-write,cn=access-control,cn=groups,<LDAP-BASE>])/uniqueMember*" write
   by set.expand="user & [cn=$1-read,cn=access-control,cn=groups,<LDAP-BASE>]/uniqueMember*" read
   by * none

And the corresponding rule for memberUid:

### The memberUids must only contain users that exist in the corresponding OU
access to dn.regex="^.+,ou=([^,]+),<LDAP-BASE>$$" attrs=memberUid val.regex="(.+)"
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([ldap:///ou=$1,@%@ldap/base@%@??sub?(&(cn=${v1})(objectClass=posixGroup))]/entryDN/-* & []))/uniqueMember*" write
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([ldap:///@%@ldap/base@%@??sub?(&(cn=${v1})(objectClass=posixGroup))]/entryDN/-* & []))/uniqueMember*" read
   by set.expand="user & ([cn=$1-write,,cn=access-control,cn=groups,<LDAP-BASE>] + ([x=]+this/memberUid & [x=${v1}])/-1)/uniqueMember*" delete
   by set.expand="user & [cn=$1-read,cn=access-control,cn=groups,<LDAP-BASE>]/uniqueMember*" read
   by * none

Postscript

The technique discussed here actually creatively misuses ACLs to implement constraints on attribute values. It would be much preferable to use constraint_attribute from slapo-constraint for this. But currently that seems to be lacking the possibility to match substrings of the restrict URL and expand them in the set type constraint. At least it’s not documented yet. While it would maybe not change much performance wise, as it also makes use of the set ACL rule parsing mechanism, it would improve clarity of the configuration statements.

Disclaimer

The scenario is just an example to illustrate Set ACLs, inspired by the work for my employer but conceptually generic.

If you find bugs or want to discuss this topic, feel free to contact me some way or the other.

© Arvid Requate 2021