Contents
2. The option and its styles (aclparse.c, acl_parse_who)
3. What the clause means
4. The set expression language (COMPLETE)
4.1 Primary elements
4.2 The chase: /attr (postfix, the core feature)
4.2a Reduction order: where a chase binds (critical trap)
4.3 Binary operators
5. Worked examples
6. The expand style: $ substitution
7. ACI userClasses (experimental)
8. Version history (verified per branch)
9. What set= is NOT (common confusions)
10. Error and edge-case behaviour (all from source)
11. Security & performance notes
12. Quick reference card
OpenLDAP ACL set Parameter — Complete Reference
Scope: the set option in the <by> clause of access directives
(slapd.conf access / cn=config olcAccess), plus the related ACI
userClasses set / set-ref.
Reliability note: This document is derived directly from the OpenLDAP source code, not from the man pages. The official man page (doc/man/man5/slapd.access.5) contains exactly one sentence about it, and that sentence (still present verbatim in the 2.5, 2.6 and current development trees) is:
The statement set=<pattern> is undocumented yet.
So the man page itself admits it is undocumented. Everything below was verified against the parser and the evaluator in the source.
Sources used (verified line by line):
Repository : https://git.openldap.org/openldap/openldap.git
Local copy : /tmp/ol-src
Branches : master (2.7-dev, HEAD 62a0a6b, 2026-08-18)
rel24 (OPENLDAP_REL_ENG_2_4)
rel25 (OPENLDAP_REL_ENG_2_5)
rel26 (OPENLDAP_REL_ENG_2_6, e21e777, 2026-08-20)
Key files:
servers/slapd/aclparse.c option parsing (acl_parse_who())
master ~line 1137
2.5/2.6 line 1508
2.4 line 1587
servers/slapd/sets.c expression evaluator (slap_set_filter(),
master lines 550-839; logic identical in
2.4/2.5/2.6 — a diff across all four
versions shows only trivial memory-error
handling differences)
servers/slapd/acl.c bridge + value chasing
acl_match_set() master line 2407
acl_set_gather() master line 2206
acl_set_gather2() master line ~2377
acl_string_expand() master line 2562
by-set check in acl_mask_dn() master line 1583
ACL_BUF_SIZE 1024 (master line 40)
servers/slapd/aci.c ACI userClasses set / set-ref
lines 663 / 668, default attr "template"
(SLAPD_ACI_SET_ATTR, line 51)
servers/slapd/slap.h AclRegexMatches, MAXREMATCHES = 100
NOTE: there is NO file "libraries/libslapd/str2set.c" and NO
"slapo-set" module in any OpenLDAP version 2.4-2.7. Verified by
file listings of all four branches and by git history search
(git log -S 'str2set' over 656 commits of master, back to 2016:
zero results). Any document or blog post referring to such a
file or module is describing something that does not exist in
OpenLDAP.
1. Where set may appear
set is a form of the <by> (who) clause only. It is NOT a
<what> form. The grammar string printed by the parser itself
(aclparse.c, line 2242) is:
<access clause> ::= access to <what> by <who> ...
<what> ::= * | dn[.<dnstyle>]=<DN>] [filter=<filter>] [attrs=<attrspec>]
set is not in that list, and a set=... token in the to clause
fails with expecting <what> got "set".
Syntax:
access to <what>
by set[.<style>]=<setexpression> <access>
cn/config: same parser, so olcAccess behaves identically.
2. The option and its styles (aclparse.c, acl_parse_who)
set[.<setstyle>]=<pattern>
Verified behaviour from the parser:
| Style on the option | Meaning |
|---|---|
(none), exact, baseObject, base |
default: pattern is evaluated verbatim |
expand |
pattern text is first run through $-substitution (section 6), then evaluated |
regex |
deprecated: silently converted to expand, with a config-log message: deprecated set style "regex" in <by> clause; use "expand" instead |
anything else (one, sub, children, level{n}, ip, path, …) |
config error: inappropriate style "<style>" in by clause |
Other verified parser rules:
- Only one
setperbyclause — a second one fails the whole config line withset attribute already specified. - An empty pattern fails with
no set is defined. - The pattern is stored verbatim; no validation of the expression is done at config time. A syntactically invalid expression is only discovered at evaluation time (per request), where it is silently treated as “no match” (section 10).
3. What the clause means
by set=<expr> grants the access to the operation iff the set
expression evaluates to a NON-EMPTY set for the entry currently
being checked. The set itself is a set of strings (almost always DNs);
only its emptiness matters — the results parameter of
slap_set_filter() is passed as NULL from the ACL code
(acl.c line 2463):
rc = ( slap_set_filter( acl_set_gather,
(SetCookie *)&cookie, &set,
&op->o_ndn, /* "user" */
&e->e_nname, /* "this" */
NULL ) > 0 );
Consequences:
user=op->o_ndn— the normalized DN of the operation’s authorized identity (the same identity used byby dn=; with proxyAuthz this is the proxied identity). If the operation is anonymous (useris NULL), usinguserin the expression makes the evaluation fail and the clause is skipped.this=e->e_nname— the normalized DN of the entry being checked. In a search this is evaluated once per result entry; for modify/delete/compare it is the target entry.- Any evaluation error (bad syntax, unknown attribute, anonymous user, …) makes the clause behave as if it did not match — silently. This is the #1 debugging pitfall; see section 11.
4. The set expression language (COMPLETE)
Source: slap_set_filter() in servers/slapd/sets.c (lines 550-839).
It is a small stack-based character parser. The following is the
entire language — the parser has exactly these cases and nothing
else:
<expr> := <term> { ( '|' | '&' | '+' ) <term> } left-associative
<term> := '(' <expr> ')'
| this
| user
| '[' <literal> ']'
| <expr> '/' <attr> { '*' | '-' <number> | '-' '*' }
Whitespace (space, tab, LF, CR) is ignored anywhere. -> is accepted
in place of / (the - case consumes a following > and falls
through to the / case). The expression stack is 64 deep — deeper
nesting is an overflow error.
4.1 Primary elements
| Element | Meaning (source) |
|---|---|
user |
Set containing the single DN of the authorized user (op->o_ndn). Only at the start or after an operator — an error if it follows a set or /. Error (clause skipped) for anonymous operations. |
this |
Set containing the single DN of the entry currently checked (e->e_nname). Same position rules. |
[literal] |
A one-element set containing the literal text between the brackets. Everything is allowed except ] (no nesting, no escapes). An empty literal [] yields one empty string — it pollutes unions and kills intersections. Must start an expression or follow an operator. |
(...) |
Grouping. Operators may appear before a parenthesized group: A & (B | C) works; note the operator sits on the left of the (. |
this and user are matched case-sensitively with memcmp —
This, USER etc. are NOT recognized and are instead treated as
attribute names (and fail, because they don’t follow a /).
4.2 The chase: /attr (postfix, the core feature)
For every DN in the current set, take the values of attribute
<attr> from the entry that DN points to, and replace the DN by
those values. Details from acl_set_gather() /
acl_set_gather2() (acl.c lines 2206-2405):
- The attribute must exist in the schema;
slap_bv2ad()failure is a syntax error (clause skipped). - The target DN is first normalized (
dnNormalize); a value that is not a valid DN contributes nothing. - The attribute is read via
backend_attribute(..., ACL_NONE)— i.e. bypassing access control entirely. Whoever evaluates the ACL can, via chasing, read attribute values of any entry in the DIT, regardless of what other ACLs say. (See section 12.) /entryDN(or the alias/dn) normalizes the DN strings in the set. Useful because set intersection is exact byte comparison (bvmatch= memcmp) — e.g. a literal written with different spacing/case in attribute names will not match a raw DN string from an attribute unless both sides are normalized with/entryDN.- LDAP URI expansion: any value in a set that starts with
ldap:///is expanded by an internal search of the local DIT: the URL’s DN (search base), scope and filter are used, the host and extension parts must be absent (local search only), and the chased attribute plus any attrs listed in the URL are requested. This is how a set expression can behave like a “filter:” form (section 5, example E5). /attr*— transitive closure. With the trailing*,set_chase()iterates the chase to a fixpoint (newly added values are chased in turn), so/member*resolves nested/recursive groups. The source comment: the attribute must have distinguishedName syntax (or values that expand as LDAP URIs)./attr-Nreplaces each DN by its N-th ancestor (dnParentapplied N times; N=0 leaves it unchanged; a DN with no N-th parent stays as-is, e.g. a root DN)./attr-*replaces each DN by itself plus all its ancestors up to the root (set_parents()in sets.c).
Chase chains compose: this/member/entryDN = chase member, then
normalize the result.
4.2a Reduction order: where a chase binds (critical trap)
The parser is a stack machine (slap_set_filter): a /attr chase
consumes only the immediately preceding term — the set on top of
the stack when the attribute name is read. A pending binary operator
sits under that term on the stack and is reduced only when a )
closes the group that opened above it, the next operator character
is read, or input ends.
Consequence: a chase never sees the result of a pending operator.
To chase the result of a concatenation — the classic case: building
an ldap:/// URI from literal + user + literal — the group
containing the concatenation MUST close before the chase:
BROKEN (([A] + user + [B])/entryDN/member) & this
at /entryDN the stack holds: ( [A]+user + [B] /
-> the chase chases the literal [B] alone. If [B] is
not a valid DN it contributes nothing; the pending
'+' is reduced LATER at ')' and joins the prefix
with the already-destroyed set.
WORKS ([A] + user + [B])/entryDN/member & this
^^ this ')' closes the group: the pending
'+' is reduced here, the fully
concatenated string exists as ONE set,
and only then does /entryDN chase it.
Two compounding subtleties (both bit a production deployment):
- Everything between
[and]is literal, so the group-close paren cannot sit inside the brackets. If the literal itself is a closing paren, the working text is the four-character sequence[)])(literal close-paren, then group close). Writing both parens inside the brackets ([))) makes the chase target a string with a double paren -> invalid filter -> internal search matches nothing. - Everything fails silently: no config error (set patterns are not validated, section 10), no runtime error — the clause simply never matches.
Diagnosis: enable ACL debug and read the intermediate
ACL set[n]=<value> lines. They are printed at each reduction step,
so for a + expression the concatenated string appears before
the chase runs. In the production incident behind this note, the
log printed the concatenated URI missing its trailing literal
paren one line before ACL set: empty — which identified the trap
in minutes, whereas re-deriving the parse in your head is exactly
where it hides.
4.3 Binary operators
Left-associative, no precedence (evaluation order is textual):
| Op | Meaning (source: slap_set_join) |
|---|---|
\| |
union; duplicates removed; left side order preserved |
& |
intersection; result empty if either side empty |
+ |
“add”: the set of all pairwise string concatenations of the two sets (concatenating with an empty string is skipped, i.e. yields the other side); duplicates removed |
+ is the way to build DN strings from pieces before a chase or an
intersection, e.g. [uid=] + user/uid + [,dc=example,dc=com]
(reconstructs the user’s own DN). Note that is the user atom,
not a literal [user] — the literal would be the four characters
“user”, is not a valid DN, and would silently collapse the whole
set to empty. And when the chase target is itself the result of a
+, the reduction-order rule of section 4.2a applies: close the
group before the chase.
5. Worked examples
All of these are directly supported by the grammar above.
E1 — Reverse group membership (transitive), no memberOf needed. Allow read to entries whose owning group (checked per entry) transitively contains the user:
access to dn.subtree="ou=people,dc=example,dc=com"
by set="[cn=admins,ou=groups,dc=example,dc=com]/member* & user" read
by * none
literal {admins DN} --/member*--> all members, incl. members of members
& {user DN} -> non-empty?
E2 — Forward membership with memberOf (requires memberOf values, e.g. from slapo-memberof or the dynlist memberOf feature):
access to *
by set="user/memberOf & [cn=admins,ou=groups,dc=example,dc=com]" read
by * none
E3 — User is a direct member of the checked group entry (the
checked entry this is the group; its member values are chased):
access to dn.subtree="ou=groups,dc=example,dc=com"
by set="this/member & user" write
E4 — Ancestor-based ACL: the user DN is under the target entry’s organizational context:
access to dn.subtree="dc=example,dc=com"
by set="user/entryDN-1 & this" read
user/entryDN-1 = the user's parent DN; intersect with {this}.
E5 — Search-style selection via LDAP URI (the set runs an
internal search; no filter: keyword exists — the URI is the
filter):
access to *
by set="[ldap:///ou=people,dc=example,dc=com??sub?(ou=staff)]/entryDN & user" read
E6 — Literal membership of a known fixed set of DNs:
by set="[uid=alice,ou=people,dc=example,dc=com] | [uid=bob,ou=people,dc=example,dc=com] & user" read
E7 — Operator before a parenthesized group (valid; the &
binds to the following (...) group):
by set="user/memberOf & ([cn=admins,ou=groups,dc=example,dc=com] | [cn=operators,ou=groups,dc=example,dc=com])" read
E8 — Dynamic URI: a service account reads the members of the group it owns (production pattern; the URI is built from the bound DN at request time, so ONE rule covers every current and future service account — no per-account ACL lines):
# DIT: group entry carrying both links:
# cn=svc,ou=service-groups,dc=example,dc=com
# objectClass: groupOfNames
# owner: uid=svc-bind,ou=service-accounts,dc=example,dc=com
# member: uid=alice,ou=users,dc=example,dc=com
access to dn.subtree="ou=users,dc=example,dc=com"
by set="([ldap:///ou=service-groups,dc=example,dc=com??sub?(owner=] + user + [)])/entryDN/member & this" read
by * break
Evaluation for bound user = the owner: the internal search
(owner=<bound DN>) finds the group -> /entryDN -> /member gives
its members -> & this is non-empty exactly for those entries.
The four-character segment [ ) ] ) is the literal filter-closing
paren followed by the group close; the group MUST close before
/entryDN (section 4.2a) or the chase chases the bare literal ")"
and the clause never matches. `by * break` (NOT `by * none`)
lets non-matching identities fall through to later rules. The
companion rule for the group subtree itself is
by set="user & (this/owner)" read by * break.
Adding a service later = one group entry (member+owner) plus one
account entry. Zero ACL edits.
6. The expand style: $ substitution
With set.expand=... (or the deprecated set.regex=...), the pattern
text is first expanded by acl_string_expand() (acl.c line 2562):
| Token | Substitutes |
|---|---|
$N, ${N} |
N-th DN submatch (0 = whole match); up to MAXREMATCHES = 100 |
${vN} |
N-th attribute-value submatch (only available if the <what> clause has a val.regex= pattern) |
${dN} |
explicit DN match (same as $N) |
$$ |
literal $ |
$ at end of pattern |
literal $ |
Where the submatches come from (acl.c, acl_mask_dn, lines 1590-1640):
<what>isdn.regex=<pattern>with a real pattern → that pattern’s submatches against the entry’s normalized DN.<what>isattrs=...val.regex=<pattern>→ value submatches (use with${vN}).<what>is*,dn=...(base/exact) → pseudo-match:$0= the full normalized DN of the entry.<what>isdn.one/dn.sub/dn.children→$0= full DN,$1= the rightmostlen(<what-DN>)characters of the entry DN.
Example:
access to dn.subtree="dc=example,dc=com"
by set.expand="user & [$0]" read
$0 expands to the DN of each entry being checked; the clause then
matches exactly for the user’s own entry (a self-like behaviour
built from pieces).
Buffer limit: the expanded text is written into a 1024-byte
buffer (ACL_BUF_SIZE, acl.c line 40). Longer expansions are
silently truncated. If a submatch index does not exist
(e.g. $1 with a dn.base what clause), the expansion fails and the
clause is skipped.
7. ACI userClasses (experimental)
The same engine is also reachable from experimental ACIs (OpenLDAPaci, requires the dynacl/ACI build; servers/slapd/aci.c):
userClass set: <setexpression> (line 663, same semantics as by set=)
userClass set-ref: <DN>[/<attr>] (line 668)
For set-ref, the first value of <attr> on the entry <DN> is
fetched (with ACL_NONE) and that value is used as the set
expression. The default attribute when none is given is literally
"template" (SLAPD_ACI_SET_ATTR, aci.c line 51) — a quirk of the
experimental ACI implementation, not a general default.
8. Version history (verified per branch)
| Version | by set= |
Notes |
|---|---|---|
| 2.4 | works (parsed at aclparse.c:1587, same evaluator) | completely absent from the man page |
| 2.5 | works | man page adds set[.<setstyle>]=<pattern> to the synopsis with <setstyle>={exact|expand}, body still: “is undocumented yet” |
| 2.6 | works | man page unchanged, still “undocumented yet” |
| 2.7 (master, 2026) | works | code moved to servers/slapd/; behaviour identical |
The set evaluator (servers/slapd/sets.c) is functionally identical across 2.4 → 2.7 (verified by diff; only NULL-handling additions).
9. What set= is NOT (common confusions)
None of the following exist in the ACL set expression — verified by
reading the whole parser (sets.c) and the option handler
(aclparse.c):
and(...),or(...),not(...)keywords — the operators are the literal characters&,|,+; there is no negation operator at all (work around it with&/|and literals, or a different mechanism).list:<name>set-list references — do not exist.filter:<filter>prefixes — do not exist (use anldap:///URI value instead, section 4.2/E5).attr[:=value]forms — do not exist (use/attrchasing).base.dn/sub.dn/one.dnscope styles as part of the pattern — do not exist; the option style accepts only exact/base/expand (section 2).- A
setform in the<what>clause — does not exist.
If you have seen a “set” syntax with list:/filter:/and()/or()
/not(), it comes from a different mechanism or product, not from
the OpenLDAP ACL set option.
10. Error and edge-case behaviour (all from source)
| Situation | Behaviour |
|---|---|
| Invalid expression syntax (config time) | accepted — no validation |
| Invalid expression syntax (runtime) | clause silently skipped (no match) |
Unknown attribute in /attr |
runtime syntax error → clause skipped |
Anonymous operation + user in expression |
error → clause skipped |
| Chased DN does not exist / is not a valid DN | that value contributes nothing |
Missing submatch in expand ($1 with base what) |
expansion error → clause skipped |
| Expanded pattern > 1024 bytes | silent truncation |
| Nesting deeper than 64 | overflow error → clause skipped |
set= repeated in one by clause |
config error: set attribute already specified |
set= with empty pattern |
config error: no set is defined |
set.regex= |
works as expand + deprecation log message |
empty literal [] |
one-element set containing the empty string |
/attr while an operator is pending below its operand (A + B/attr) |
no error — the chase binds to B alone; if B is a non-DN (e.g. the literal ")") the result is silently empty (section 4.2a) |
Debugging aid: every evaluation is logged with slapd -d acl
(LDAP_DEBUG_ACL): acl.c prints <= check a_set_pat: <pattern> and
sets.c prints either ACL set: empty or ACL set[n]=<value> for the
result. The intermediate values are printed at EACH reduction step —
for + expressions the concatenated string appears in the log BEFORE
the chase runs. When a chase comes back empty, read that line: if a
piece is missing from the string (e.g. a literal paren), you have the
reduction-order trap (section 4.2a), not a schema or data problem —
and the clause text in check a_set_pat confirms what the parser
actually stored.
slapacl cannot test by set= clauses — servers/slapd/slapacl.c
contains no set handling at all; it silently ignores them. Validate
set ACLs against a running slapd with ACL debug logging, or a
dedicated test database.
11. Security & performance notes
- Chasing bypasses ACLs.
backend_attribute(..., ACL_NONE)reads the chased attribute of any entry without access checks. An ACL clause that chasesdescriptionof arbitrary entries lets the very evaluation of that clause disclose those values in debug logs, and means yourby set=grant implicitly carries read access to whatever you chase. Choose chased attributes deliberately (member, entryDN are the safe, intended ones). - Cost. Each chase is a backend lookup per DN in the set, per
entry checked. In searches,
by set=is evaluated per result entry./attr*(closure) andldap:///URI expansion (internal searches) can be very expensive on large DITs. Do not put expression-heavy set clauses on hotaccess to *lines. - Exact string matching. Intersections compare strings byte-for-
byte. Normalize both sides with
/entryDNwhen comparing DNs from different sources. DN value case is preserved by normalization (only attribute names are lowercased), socn=Adminsandcn=adminsdo not match. thisdepends on the entry being checked — in a search, results are filtered per entry, so one clause can match some entries of a result and not others.
12. Quick reference card
by set[.exact|base|expand]=<expr> regex= is a deprecated alias for expand
<expr> := <term> { ('|' | '&' | '+') <term> }
<term> := '(' <expr> ')' | this | user | '[' literal ']'
| <expr> '/' <attr> ['*' | '-' <n> | '-' '*']
this DN of the entry being checked
user DN of the authorized (proxy-aware) user
[x] literal string set
/attr chase attribute from each DN (ACLs bypassed!)
/attr* transitive chase (nested groups)
/attr-N N-th ancestor of each DN (N=0: unchanged)
/attr-* DN + all its ancestors
ldap:///... any set value that is a local LDAP URI runs an
internal search (no host/exts allowed)
& | + intersection / union / pairwise concatenation
$0 $N ${vN} ${dN} $$ expand-style substitution (1024-byte buffer)
Source-verified against OpenLDAP 2.4-2.7 (sets.c, acl.c, aclparse.c)
Maintainer Marc-Robin Wendt (marc-robin.wendt@tu-berlin.de)
this page last updated: 31-Aug-2026