This post is part of a series on directory services. Current available installments are:
- Introduction
- Terminology
- Basic concepts
- Designing the DIT
- Setting up an LDAP server
- Securing your LDAP server
- Writing and testing ACLs
ACLs, access control lists, are an important aspect of running a directory
service. ACLs are how you control who can access which parts of the DIT and
what things they can do. You can limit certain things like which attributes
one can read or write. This also enables you to write ACLs that allow users
to update certain of their own attributes, say their displayName
, allowing
a form of self-management.
Table of Contents
Structure of an ACL
ACLs in OpenLDAP look pretty much like this:
access to <what>
by <who> <ssf> <level> <control>
This looks simple but can get complicated pretty fast. I’ll take each section and elaborate on it further.
When using online/dynamic configuration the access
keyword is omitted, as
ACLs are stored in the olcAccess
attribute.
what
The <what>
can be split up into three pieces: <part of the tree>
, <filter>
,
and <attributes>
.
Part of the tree
The first one, <part of tree>
allows you to qualify to
which part of the tree this applies. If it should apply to everything a
value of *
can be used. It could for example be dn.subtree="dc=example,dc=com"
for an ACL that will apply to dc=example,dc=com
and all entries below it.
Filter
The filter can be used instead of the <part of the tree>
or as a complement
to it. It has to be specified according to the LDAP search filter format.
When <part of the tree>
is not omitted, the <filter>
is optional but
can be used to specify a precondition. It can be used to gate access to an entity
based on if it has an attribute with a specific value. For example:
filter=(status=active)
. This could be used to implement a form of read-only
entities in a part of the DIT, for example employees that have left the company,
so that those can only be updated by an administrator instead.
Attributes
<attributes>
lets you specific if this ACL is limited to certain attributes.
I could write a rule that gives someone the ability to write the displayName
attribute but not the uid
attribute: attrs=displayName,givenName
.
who
The <who>
allows you to specify who this ACL applies to. It’s often
a DN, for example: group.exact="cn=admin,dc=example,dc=com"
. This would allow
any DN listed in the member
attribute of the group the level of access defined
by the ACL. When using group.exact
OpenLDAP knows that it should look at the
member
attribute to find the DN.
In our case though cn=admin
is not a groupOfNames and as such
does not have member
s. Instead it’s an organizationalRole so we’ll need to
change it to: group/organizationalRole/roleOccupant="cn=admin,dc=eample,dc=com"
instead. You can do some really clever stuff with this one too. For example, the
inetOrgPerson has a manager attribute which should be the DN of whomever
this person reports to. Using that in the who part of the ACL allows you to set
up the directory service in such a way that someone’s manager could manage
part of their reportees attributes, like which groups they are a member of.
You’ll probably have spotted the group.exact
here, meaning only the group
that matches the DN exactly. Sometimes you might want to grant access to any
group from a certain part of the tree, in which case you could use something
like group.base="ou=groups,ou=example,dc=example,dc=com
.
There are also a few special values for who; *
, meaning anyone (authenticated
or not), anonymous
(unauthenticated), users
(authenticated) or self
(user
associated with the entry). self
is useful in order to let you write an ACL
that says “users can manage these attributes on themselves, but not on someone
else”. Using *
or anonymous
would allow an unauthenticated user access,
so use this with caution. In general I’d recommend to never use it and always
require authentication when interacting with your directory service.
Group of groups
I just showed you how you can use group
or roleOccupant
, but by default
that match is not recursive. Meaning that if the member
of the group.exact
is a DN that points at another group instead of a user, nobody who’s a member
of the nested group will be granted access.
In order to get around that you need to expand the members, essentially do a
recursive lookup. The way that is done is by using a set
instead.
access to *
by set="[cn=admin,dc=example,dc=com]/roleOccupant* & user" manage
The roleOccupant*
means “recursive expand roleOccupant”. The & user
means
“check the authenticated user’s DN against it”. If the result of this
operation is non-empty, meaning the authenticated user’s DN matched at least
once while expanding roleOccupant
recursively, they are granted access.
Note that any entity mentioned in the roleOccupant
attribute of cn=admin
has to be either a user or another entity with a roleOccupant
attribute.
This means that if you put a groupOfNames in there which has a member
attribue instead it won’t look those up and the & user
won’t match, resulting
in an empty set and therefor access being denied.
ssf
The <ssf>
allows you to specify a Security Strenght Factor that needs to be
met for this ACL to apply. For example, tls=256
to ensure that this ACL only
applies when a TLS connection negotiated with a 256 bit key. It’s rare to see
these.
level
The <level>
is the last one, and can be one of none
, disclose
, auth
,
compare
, search
, read
, write
or manage. These form a chain, so manage
implies everything beneath it. In practice it’s rare to see disclose
,
compare
, or search
.
Every ACL ends with an implicit by * none
who clause, disallowing access when
nothing matches.
You’ll typically see read
, write
and manage
used the most. auth
is
interesting and usually paired with anonymous
in one specific case:
access to attrs=userPassword
by self write
by anonymous auth
The by anonymous auth
is necessary so that during an authenticatin, a BIND,
the server is allowed to read the (hashed) password stored in userPassword
and compare against the one provided in the BIND operation. Using by self write
means that once authenticated they’re allowed to change their own password.
control
The <control>
influences the evaluation of the ACLs, which we’ll discuss
in the next session. For now keep this in mind: it defaults to stop
if
omitted, and can be set to continue
or break
.
Evaluation of ACLs
By default these rules apply:
- Only the first
what
that matches is considered - Within a
what
, only the firstwho
that matches is considered - If nothing matches, no access is granted as it matches the implicit
by * none stop
Essentially it means that the evaluation of an ACL will stop on the first
match, even if multiple could apply. Therefore, the ordering of the what
and who
clauses affect the access granted.
Getting around that means changing the <control>
part of an ACL. When
evaluating the who
’s of a what
, if you specify a control value of
continue
the next who
clause for this what
will also be considered.
If it matches the access level will be AND
ed with previous matches.
This still only lets you take into account multiple who
clauses, but
perhaps multiple what
clauses also match. In that case setting the
control value to break
will cause it to evaluate other what
’s too.
These rules, and how you can affect them by changing the control value, is what makes writing ACLs for OpenLDAP rather tricky and error prone. A simple logic mistake could lock even an administrator out of the system.
As such it’s usually helpful to ensure you have an ACL like this in place as the first ACL:
access to dn.subtree="dc=example,dc=com"
by group/organizationalRole/roleOccupant="cn=admin,dc=example,dc=com" manage
Assuming you’re a member of cn=admin
, no matter how you screw up the ACLs
you’ll always retain full administrative access to the DIT.
Applying ACLs
cn=config
is where the configuration of the directory service itself is
stored. This is separate from the DIT with all your entities, and when
connecting to the directory service you’ll have to specify cn=config
as your
base DN.
You can do this in Apache Directory Studio by unchecking the “Get base DNs from
root DSE” and specifying a “Base DN” of cn=config
. Note that only cn=Manager
has access to cn=config
so you might need to update the settings in the
“Authentication” tab too.
In order to set ACLs we need to modify the olcAccess
attribute on the
olcDatabase
entries underneath cn=config
. To set ACLs that control access
to the DIT you’ll have to modify olcDatabase={X}mdb
. The X
will be 2
in
the case of the configuration generated by slapddgen but it can vary per
environment.
In order to grant additional people access to cn=config
itself you’ll have
to update the olcAccess
on olcDatabase={0}config
. Be really careful when
doing that. If you botch this one and lock all the admins out you’re in for
a world of hurt.
olcAccess
is multivalued, but since the order matters for the ACL evaluation
they’re prefix by a {X}
, where X=0
is the first entry/highest priority
and so on.
Slapddgen will have generated a few ACLs for you, so you should see this:
{0}to dn.subtree="dc=example,dc=com" by group/organizationalRole/roleOccupant="cn=admin,dc=example,dc=com" manage by * break
{1}to attrs=userPassword by self write by group.exact="cn=readSecret,ou=groups,ou=example,dc=example,dc=com" read by anonymous auth
{2}to attrs=sn,displayName,mail,givenName,initials,mobile,preferredLanguage,title,telephoneNumber by self write by users read
{3}to dn.subtree="dc=example,dc=com" by users read
Lets break that down. The first entry specifies that anyone who’s listed as
a roleOccupant of cn=admin,...
gets the manage
level of access on
everything from the base DN onwards. This is the highest level of access,
meaning they can read and write everything.
You’ll see that at the end of the line I have a break
there. That’s
because I have a second entry pertaining to dn.subtree="dc=example,dc=com"
granting all (authenticated) users read
access. I could not do this and
simply have a second who clause of by users read
. Mind you though that
if you do that ACL 1 and 2 would never get evaluated unless we added a
break
to it too. This is why things can get complicated, fast.
ACL 1 and 2 are largely there for self-management. userPassword
can be
updated by oneself, accessed by anonymous for authentication purposes and
read by a special readSecret
group. This group doesn’t necessarily exist
but it’s an example of how you could grant access to such an attribute to
all members of a specific group.
ACL 2 gives oneself the ability to update a number of administrative attributes on their own inetOrgPerson entity and lets everyone else read those values. This makes for a handy telephone book.
Notice how ACL 2 explicitly specifies by users read
. That’s necessary
since otherwise any other user wouldn’t be able to read those attributes,
despite ACL 3 because by default we’d never get there. We could omit that
and instead do by self write break
which should ensure we continue
reading the ACLs, end up at number 3 and gain read access. Feel free to
test this out.
Testing ACLs
This is where it gets really annoying. In order to test an ACL you
basically have to authenticate as the who
thats supposed to get access
to the what
and verify you have access to it, and haven’t lost access
to any other what
.
This is a rather error prone process and the consequences of getting it wrong in produciton can be rather annoying. Therefore, don’t test this in production!
Using slapddgen and some seed files you can easily create a testing copy of your directory service and have at it. If you get it wrong all you need to do is restart the container and you’ve got a clean slate. Once you’re satisfied with the changes, apply them to production.
Thankfully we can apply some automation to this! I use pytest for this, paired with pytest-docker and the ldap3 library.
Using pytest and ldap3 I can write tests against a “real” directory service. pytest-docker is responsible for spinning up a Docker container with my directory service and spinning it down at the end of the test run.
Leveraging pytest and pytest-datadir I can load the seed files from the tests directory and apply them to the directory service using the ldap3 library. Once that’s done I can use the ldap3 library to execute queries against the directory service allowing me to validate ACLs.
When you combine this with pytest’s fixtures it becomes very easy to write tests that do a cycle of “modify directory service, check if everything still works, revert changes” and move on to the next test.
Other resources
There’s a lot more resources on ACLs in OpenLDAP:
- Zytrax’s LDAP for Rocket Scientists
- OpenLDAP Faq-o-Matic on Access control
- Access Control in the OpenLDAP Administrator Guide
- Keeping your sanity while designing OpenLDAP ACLs
Now that you have a directory service you can expand on your capabilities by implementing a BeyondCorp style access layer to protect networked services and applications.