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:
- A OU admin must not be able to see users and groups from other OUs
- A OU admin must not be able to add users from other OUs to the groups in their OU
- A OU admin must be able to assign read or write access to groups in their own OU only
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:
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 `
## 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 <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.