{"id":499698,"date":"2026-10-22T08:01:22","date_gmt":"2026-10-22T05:01:22","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=499698"},"modified":"2026-10-22T08:01:22","modified_gmt":"2026-10-22T05:01:22","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=499698","title":{"rendered":"How a helpdesk role could take over the admin: five traps in a web console for Active Directory and OpenLDAP"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>In Axia 0.1.0, a user allowed to edit accounts and move OUs could reset an administrator\u2019s password in four requests. None of the four broke the rule written to prevent exactly that.<\/p>\n<p>Axia is an open-source web console and REST API for Active Directory and OpenLDAP (.NET 10, React). It exists for a routine every company with a directory knows: create an account, reset a password, add someone to a group, disable a leaver. That work belongs with helpdesk, but helpdesk must not be able to promote itself, and every action must leave a trace.<\/p>\n<p>This is not a feature tour. It is five places where the obvious implementation turned out to be a hole or a bug, and what fixed each one. Most of them have nothing to do with Axia: anyone who builds access control on top of LDAP will run into them. The code is linked at the end.<\/p>\n<h3>Why not directory ACLs<\/h3>\n<p>The textbook way to let helpdesk reset passwords is delegation: OU permissions in AD, <code>olcAccess<\/code> rules in OpenLDAP. The directory itself decides who may do what, and no middleman needs broad rights.<\/p>\n<p>In practice this has two problems. The first is complexity. \u201cSupport resets passwords in <code>ou=people<\/code>, except for members of <code>cn=admins<\/code>\u201d can be written in OpenLDAP, but such rules are hard to read and maintain, and a mistake in their order silently grants more than intended. The second is that the rules live on the directory tree while the questions are about people: \u201cwhat can Peters do?\u201d has to be pieced together from the ACLs of several OUs.<\/p>\n<p>Axia takes the other route, and its price is worth naming up front. The application binds to the directory with a service account that has broad rights, and decides on its own who may do what: directory groups map to roles, and roles to a set of 17 permissions.<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/extt\/4a\/6f\/ff\/4a6fff9cc5830bafe2f6ee5d14129e3a.png\" alt=\"Directory groups mapped to roles\" sizes=\"(max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/extt\/4a\/6f\/ff\/4a6fff9cc5830bafe2f6ee5d14129e3a.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/extt\/4a\/6f\/ff\/4a6fff9cc5830bafe2f6ee5d14129e3a.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>Directory groups mapped to roles<\/figcaption><\/div>\n<\/figure>\n<p>Permissions are evaluated on every request from the account\u2019s current groups, with a 30-second cache. Take someone out of a group, and half a minute later the rights are gone, even if their token is still valid.<\/p>\n<p>The more important consequence is another one. Since the service account makes every change, the directory no longer protects against privilege escalation. That is now the application\u2019s job, and nearly every trap below is about it. And whoever breaks into the application gets the directory, as far as the service account\u2019s rights reach. Weigh that before choosing the approach.<\/p>\n<h3>How it compares<\/h3>\n<p>It makes more sense to compare approaches than products: where permissions are checked and what you pay for it. Axia is one option here, and not the best one everywhere.<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">\n<\/th>\n<th>\n<p align=\"left\">Delegation in AD + RSAT<\/p>\n<\/th>\n<th>\n<p align=\"left\">phpLDAPadmin, LAM<\/p>\n<\/th>\n<th>\n<p align=\"left\">Adaxes, ADManager Plus<\/p>\n<\/th>\n<th>\n<p align=\"left\">Axia<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Who checks permissions<\/p>\n<\/td>\n<td>\n<p align=\"left\">the directory (ACLs)<\/p>\n<\/td>\n<td>\n<p align=\"left\">the directory (ACLs)<\/p>\n<\/td>\n<td>\n<p align=\"left\">the application<\/p>\n<\/td>\n<td>\n<p align=\"left\">the application<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">The tool itself holds broad rights<\/p>\n<\/td>\n<td>\n<p align=\"left\">no<\/p>\n<\/td>\n<td>\n<p align=\"left\">no, signs in as the user<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes, a service account<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes, a service account<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Directories<\/p>\n<\/td>\n<td>\n<p align=\"left\">AD<\/p>\n<\/td>\n<td>\n<p align=\"left\">any LDAP<\/p>\n<\/td>\n<td>\n<p align=\"left\">AD, Microsoft 365<\/p>\n<\/td>\n<td>\n<p align=\"left\">AD, OpenLDAP, eDirectory<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Admins protected from password resets<\/p>\n<\/td>\n<td>\n<p align=\"left\">AdminSDHolder, built in<\/p>\n<\/td>\n<td>\n<p align=\"left\">if your ACLs say so<\/p>\n<\/td>\n<td>\n<p align=\"left\">its own delegation model<\/p>\n<\/td>\n<td>\n<p align=\"left\">adminCount, protected groups, \u201cno one above yourself\u201d<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Change log<\/p>\n<\/td>\n<td>\n<p align=\"left\">DC security log, if auditing is on<\/p>\n<\/td>\n<td>\n<p align=\"left\">OpenLDAP accesslog, if configured<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes<\/p>\n<\/td>\n<td>\n<p align=\"left\">yes, refusals included<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">What it runs on<\/p>\n<\/td>\n<td>\n<p align=\"left\">Windows with RSAT<\/p>\n<\/td>\n<td>\n<p align=\"left\">a web server with PHP<\/p>\n<\/td>\n<td>\n<p align=\"left\">a server and a licence<\/p>\n<\/td>\n<td>\n<p align=\"left\">Docker or .NET<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Maturity<\/p>\n<\/td>\n<td>\n<p align=\"left\">years<\/p>\n<\/td>\n<td>\n<p align=\"left\">years<\/p>\n<\/td>\n<td>\n<p align=\"left\">years, vendor support<\/p>\n<\/td>\n<td>\n<p align=\"left\">MVP, version 0.2<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>When you don\u2019t need Axia:<\/p>\n<ul>\n<li>\n<p><strong>AD only, administrators live in RSAT, delegation is in place.<\/strong> Nothing to change: AdminSDHolder already strips delegated rights from privileged accounts.<\/p>\n<\/li>\n<li>\n<p><strong>OpenLDAP, and you write <\/strong><code><strong>olcAccess<\/strong><\/code><strong> with confidence.<\/strong> LAM gives you a web interface with no privileged middleman. That is strictly safer than Axia\u2019s model.<\/p>\n<\/li>\n<li>\n<p><strong>You need request approval, workflows, auditor reports and vendor support.<\/strong> That is Adaxes and ADManager Plus territory.<\/p>\n<\/li>\n<\/ul>\n<p>Axia makes sense when there are several directories or OpenLDAP is among them, when you want to grant rights through groups rather than ACLs, and when you need a log that shows refusals too. And when you need an API, not just a UI.<\/p>\n<h3>Trap 1: moving an OU to become admin<\/h3>\n<p>The <code>users.update<\/code> permission says what change you may make, but not to whom. And every change reaches the directory as the service account, which can modify anyone. In an early version that was enough for the UserEditor role to reset an administrator\u2019s password and sign in as them, for <code>users.delete<\/code> to remove every administrator, and for <code>groups.manage<\/code> to add oneself to the group mapped to the Admin role.<\/p>\n<p>Hence the rule: you cannot act on anyone who holds more than you do. \u201cMore\u201d is measured in two places:<\/p>\n<ul>\n<li>\n<p><strong>In the application.<\/strong> The target\u2019s permissions must be a subset of the caller\u2019s.<\/p>\n<\/li>\n<li>\n<p><strong>In the directory.<\/strong> Accounts and groups the directory itself treats as privileged (<code>adminCount<\/code> in AD, and the groups listed under <code>DirectoryProtection<\/code>) can be changed only by someone who holds every permission in the application. Without this, a domain admin whose groups mapped to no role was a plain User to Axia, and any editor outranked them.<\/p>\n<\/li>\n<\/ul>\n<p>Nobody may change the service account: resetting its password through the application breaks the application at best, and at worst hands its rights to whoever chose the new password.<\/p>\n<p>The rule was there, and so were its tests. Then a hole turned up beside it. Roles are mapped to groups by DN: <code>cn=axia-admins,ou=groups,dc=example,dc=org<\/code> \u2192 Admin. But a DN is an address, not an identifier. A user with <code>orgunits.manage<\/code> and <code>users.update<\/code> needed four steps:<\/p>\n<ol>\n<li>\n<p>Move the OU that holds the administrators\u2019 group. The group\u2019s DN changes, and the mapping to Admin stops matching.<\/p>\n<\/li>\n<li>\n<p>On the next request, the administrator is a plain User to the application and can be outranked.<\/p>\n<\/li>\n<li>\n<p>Reset their password.<\/p>\n<\/li>\n<li>\n<p>Move the OU back. Everything looks as before, except that the administrator\u2019s password is now known.<\/p>\n<\/li>\n<\/ol>\n<p>No single step broke the rule. The hole was in the first public release, 0.1.0, and closed in 0.2.0. The fix is to judge an OU by what it contains. Renaming or moving an OU changes the address of its whole subtree, so everything in that subtree is checked:<\/p>\n<pre><code>var subtree = await _ldapService.InspectSubtreeAsync(unit.Value);if (subtree.HoldsServiceAccount)    throw Refuse(unit.Value, \"it holds the service account\", ...);var held = await CallerPermissionsAsync(cancellationToken);if ((subtree.HoldsPrivileged || holdsProtectedGroup) &amp;&amp; !HoldsEverything(held))    throw Refuse(unit.Value, \"it holds entries privileged in the directory\", ...);\/\/ Mappings by DN only: a mapping by name matches wherever the group is, so moving it\/\/ changes nothing about it.var roles = (await _roleMappings.GetEffectiveMappingsAsync(_actingUser.ProviderId))    .Where(mapping =&gt; !mapping.IdentifiesGroupByNameOnly                      &amp;&amp; DistinguishedName.TryCreate(mapping.GroupName, out var group)                      &amp;&amp; group!.IsWithin(unit))    .Select(mapping =&gt; mapping.RoleName)    .ToHashSet(StringComparer.OrdinalIgnoreCase);var conferred = await _rolePermissions.GetPermissionsForRolesAsync(roles, cancellationToken);EnsureCallerHolds(held, conferred, unit.Value);<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper\"><\/span><\/a><\/div><\/pre>\n<p>Deleting an OU needs no such check: the directory will not delete one that isn\u2019t empty. Computer accounts follow the same rules, and only a full administrator can change a domain controller\u2019s account.<\/p>\n<p>Every refusal is written to the audit log together with who tried:<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/extt\/46\/33\/bd\/4633bdf167c90c06b3bd64d2624cb396.png\" alt=\"A UserEditor refused a password reset on an administrator, in the audit log\" sizes=\"(max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/extt\/46\/33\/bd\/4633bdf167c90c06b3bd64d2624cb396.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/extt\/46\/33\/bd\/4633bdf167c90c06b3bd64d2624cb396.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>A UserEditor refused a password reset on an administrator, in the audit log<\/figcaption><\/div>\n<\/figure>\n<p>Before 0.2.0 the log missed the most common refusal of all: a request with no permission for the action at all, turned away at the API\u2019s entrance before any business logic ran. That one is recorded now too.<\/p>\n<p>The lesson holds without Axia: if permissions hang on DNs, any operation that changes a DN, a rename or a move, is an operation on permissions. It deserves the same scrutiny as a change to group membership.<\/p>\n<h3>Trap 2: the memberOf that isn\u2019t there<\/h3>\n<p>To check permissions you need the account\u2019s groups. The simplest source is the <code>memberOf<\/code> attribute, and at first Axia read nothing else. In AD that works: <code>memberOf<\/code> is computed there and always present. OpenLDAP has no <code>memberOf<\/code> until the memberof overlay is enabled. A default installation doesn\u2019t load it, and where it is loaded it is often configured for <code>groupOfNames<\/code> and blind to <code>groupOfUniqueNames<\/code>.<\/p>\n<p>The consequences were asymmetric. The administrator signed in as a plain User: a safe failure that gets noticed on day one. But the same empty list meant nobody was in a protected group. The \u201cdon\u2019t touch the privileged\u201d rule fired for no one, and anyone allowed to reset passwords could reset the administrator\u2019s. That half goes unnoticed until someone uses it.<\/p>\n<p>Now groups are searched from the other side, as the groups that name the account, in all three forms directories use to store membership:<\/p>\n<pre><code>internal static string GroupMembershipFilter(string distinguishedName, string? uid){    var dn = EscapeLdapFilterValue(distinguishedName);    return \"(|\"        + $\"(&amp;(objectClass=groupOfNames)(member={dn}))\"        + $\"(&amp;(objectClass=group)(member={dn}))\"        + $\"(&amp;(objectClass=groupOfUniqueNames)(uniqueMember={dn}))\"        + (string.IsNullOrWhiteSpace(uid)            ? string.Empty            : $\"(&amp;(objectClass=posixGroup)(memberUid={EscapeLdapFilterValue(uid)}))\")        + \")\";}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>The result is merged with <code>memberOf<\/code>, not substituted for it. The DN is escaped: an entry\u2019s name can contain parentheses or an asterisk, and unescaped they would end the filter early or widen it to other people\u2019s groups. Nested groups are not expanded, just as <code>memberOf<\/code> doesn\u2019t expand them.<\/p>\n<p>The story has a sequel. Searching for groups is one more query per account. That is fine for sign-in and permission checks, but not for a list of thousands of users, so the list reads <code>memberOf<\/code> only. The list is cached, and the user\u2019s detail page was served from the same cache. So in a directory without <code>memberOf<\/code>, the page said \u201cnot a member of any group\u201d for people who were. Permissions were unaffected, since the check takes its own path, but the administrator was shown something untrue. In 0.2.0 the detail page re-reads the account by DN the same way sign-in does.<\/p>\n<p>The lesson: when the same data can be fetched cheaply or correctly, the cheap way will sooner or later end up where the correct one was needed. Especially through a cache.<\/p>\n<h3>Trap 3: pass-back, or how to steal the service account\u2019s password<\/h3>\n<p>Directory connections are kept in the database: host, port, encryption mode, the service account\u2019s DN and password. The password is encrypted with AES-GCM and never returned by the API, not even to an administrator. It would seem there is no way to steal it through the application.<\/p>\n<p>There is. Change the connection\u2019s host to your own and leave the password untouched. On the next request the server connects to the new address and performs a simple bind, sending the service account\u2019s password as it is. This is the well-known LDAP pass-back attack. It is usually demonstrated on printers with an LDAP address book, and a web console is no better.<\/p>\n<p>Why does it matter, if only an administrator can edit connections anyway? Because an administrator\u2019s token lives for an hour, while the service account\u2019s password lives for years and carries more rights. A stolen token must not become lasting access to the directory.<\/p>\n<p>The rule came out simple: if a connection is about to go somewhere it has never gone, the password has to be entered again. And only someone who already knows it can enter it.<\/p>\n<pre><code>var keepsStoredPassword = string.IsNullOrEmpty(request.AdminPassword)                          &amp;&amp; !existing.EncryptedAdminPassword.IsEmpty;if (keepsStoredPassword &amp;&amp; existing.WouldReachNewEndpoint(request.Host, port, request.FailoverHosts))    throw new DomainValidationException(ApplicationMessages.BindPasswordRequiredForNewEndpoint());\/\/ The same password to the same host, but readable on the way: whoever can watch the\/\/ network gets it as surely as a host of the caller's choosing would.if (keepsStoredPassword &amp;&amp; existing.WouldDropEncryption(security))    throw new DomainValidationException(ApplicationMessages.BindPasswordRequiredForPlainLdap());<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>Any host, primary or failover, that wasn\u2019t there before counts as new, and so does any new port on an existing host. Removing a failover host or reordering hosts needs no password. The second condition is the same pass-back from the other side: the host stays, but encryption is turned off, and the password crosses the network in plain text to anyone listening.<\/p>\n<h3>Trap 4: account lockout as a DoS<\/h3>\n<p>An Axia session is a JWT valid for 60 minutes. So that a dismissed employee can\u2019t keep working for another hour, every request checks whether the account has been switched off in the directory, with a 30-second cache. Each directory records \u201cswitched off\u201d its own way: the ACCOUNTDISABLE bit in <code>userAccountControl<\/code> or an expired <code>accountExpires<\/code> in AD, <code>pwdAccountLockedTime<\/code> in OpenLDAP with the ppolicy overlay, <code>loginDisabled<\/code> in eDirectory.<\/p>\n<p>The interesting part is what deliberately does not count: a lockout after failed sign-ins. Anyone can trigger one by entering a wrong password for someone else\u2019s login a few times. If a lockout ended sessions, any employee could kick an administrator out of the console mid-task. So a lockout blocks new sign-ins, since the directory refuses the bind, but leaves a live session alone. In OpenLDAP both kinds of lock live in the same attribute and are told apart by its value:<\/p>\n<pre><code>\/\/ The value OpenLDAP's password policy writes to pwdAccountLockedTime for a lock that only an\/\/ administrator lifts, as opposed to the time of a lockout that expires on its own.private const string PermanentLockTime = \"000001010000Z\";if (string.Equals(Get(attrs, \"pwdAccountLockedTime\").Trim(), PermanentLockTime, StringComparison.Ordinal))    return true;<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>The same reasoning led to a per-account sign-in limit. A per-IP limit of 5 attempts a minute does nothing against guessing from many addresses, and every attempt the API lets through to the directory is a bind that brings the account closer to a lockout in the directory itself. Now, after 5 failed sign-ins within 15 minutes, attempts for that login are refused with a 429 and never reach the directory. With the threshold below the directory\u2019s own, guessing through Axia cannot lock the account there.<\/p>\n<p>A fair caveat: this limit is a small DoS in its own right. For fifteen minutes an attacker can keep someone out of the console. But only out of the console, only for 15 minutes, with no lockout in the directory and no live session cut. The response is the same for logins that exist and logins that don\u2019t, so it reveals nothing about who is in the directory.<\/p>\n<h3>Trap 5: a password is not an attribute<\/h3>\n<p>In an early version the password was written as an ordinary <code>userPassword<\/code> attribute. It looks logical, and both directories accept the request without an error. The result is bad in both:<\/p>\n<ul>\n<li>\n<p><strong>In AD<\/strong>, <code>userPassword<\/code> is by default an ordinary string attribute (unless <code>dSHeuristics<\/code> says otherwise). The account\u2019s real password stayed the same, and the new one sat next to it in plain text, visible to everyone allowed to read the entry.<\/p>\n<\/li>\n<li>\n<p><strong>In OpenLDAP<\/strong> without a hashing policy, what you write is exactly what is stored. Every password set through the application sat in the directory unhashed.<\/p>\n<\/li>\n<\/ul>\n<p>The right way is to let the directory accept the password itself, hash it and check it against its own policy:<\/p>\n<ul>\n<li>\n<p><strong>AD:<\/strong> the <code>unicodePwd<\/code> attribute, whose value is the password in double quotes, encoded as UTF-16LE. It is accepted only over an encrypted connection; over a plain one AD answers <code>unwillingToPerform<\/code>.<\/p>\n<\/li>\n<li>\n<p><strong>OpenLDAP and the rest:<\/strong> the Password Modify extended operation (RFC 3062).<\/p>\n<\/li>\n<\/ul>\n<pre><code>public static byte[] ActiveDirectoryValue(string password) =&gt;    Encoding.Unicode.GetBytes($\"\\\"{password}\\\"\");<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>Which path to take is decided from the root DSE, not from configuration: AD announces itself with the capability <code>1.2.840.113556.1.4.800<\/code>, and Password Modify support shows up in <code>supportedExtension<\/code>. If neither is there, the password is not written at all: an error is better than a password in plain text. Both directories answer a password that is too simple the same way, with <code>constraintViolation<\/code>, and that becomes a clear 400 error rather than \u201cdirectory unavailable\u201d.<\/p>\n<h3>Bonus: a directory that accepted the connection and went silent<\/h3>\n<p>LDAP operations had timeouts, but one call ignored them. While <code>LdapConnection.StartTls()<\/code> in Novell.Directory.Ldap waits for an answer, it holds the connection\u2019s write lock, and both the timeout and closing the connection wait for that same lock. A directory that accepted the TCP connection and stayed silent held the request thread forever. LDAPS and plain LDAP in the same test gave up within a second.<\/p>\n<p>The workaround: before StartTLS, ask the directory something harmless that does respect the timeout, and read the root DSE with no attributes. Any answer will do, even a refusal: it shows someone is there. No answer means <code>LdapTimeout<\/code> while the lock is free, and the connection is closed normally. No credentials go anywhere in the process; they are sent only after TLS.<\/p>\n<pre><code>private static void StartTlsWithinLimit(LdapConnection conn){    try    {        var rootDse = conn.Search(string.Empty, LdapConnection.ScopeBase, \"(objectClass=*)\", [\"1.1\"], false);        while (rootDse.HasMore())            rootDse.Next();    }    catch (LdapException ex) when (ex.ResultCode != LdapException.LdapTimeout)    {        \/\/ An answer, if not the one asked for.    }    conn.StartTls();}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>It is not a guarantee: a server can answer the read and then hang on StartTLS. But it covers the common case of an address with the wrong service behind it, or a half-dead directory.<\/p>\n<h3>What came out of it<\/h3>\n<p>Traps aside, Axia is a web console and REST API (OpenAPI) for users, groups, OUs and computer accounts: bulk operations, a login generator with transliteration, an audit log. The server runs on .NET 10, the console on React 19; one installation serves several directories, and the interface comes in nine languages, of which English and Russian have been proofread. The current release is 0.2.1, with container images for amd64 and arm64. PostgreSQL support and a Windows installer are on <code>main<\/code> and will ship in 0.3.<\/p>\n<figure class=\"\"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/extt\/bc\/dd\/d8\/bcddd8eb8b5a5356e894a38d67ca5c72.png\" alt=\"The users list in the console\" sizes=\"(max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/extt\/bc\/dd\/d8\/bcddd8eb8b5a5356e894a38d67ca5c72.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/extt\/bc\/dd\/d8\/bcddd8eb8b5a5356e894a38d67ca5c72.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/p>\n<div><figcaption>The users list in the console<\/figcaption><\/div>\n<\/figure>\n<p>You can try it in a couple of minutes; all it needs is Docker with Compose v2:<\/p>\n<pre><code class=\"bash\">git clone https:\/\/github.com\/Nelsar\/Axia.gitcd Axiadocker compose up<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><span class=\"sc-logo-wrapper icon-only\"><\/span><\/a><\/div><\/pre>\n<p>This starts the API, the console at <a href=\"http:\/\/localhost:8080\" rel=\"noopener nofollow\">http:\/\/localhost:8080<\/a> and an OpenLDAP with demo accounts; every password is <code>Axia-Demo-1<\/code>. The most interesting thing to try is signing in as <code>editor.demo<\/code> and resetting <code>admin.demo<\/code>\u2019s password. The demo is put together the way a production setup should be: LDAPS with certificate verification, keys generated on first start, nothing exposed beyond localhost.<\/p>\n<p>What it doesn\u2019t have yet, which is better known before you deploy:<\/p>\n<ul>\n<li>\n<p><strong>Sign-out and token revocation.<\/strong> Switching an account off ends its session within 30 seconds, but a stolen token stays valid until it expires, 60 minutes by default.<\/p>\n<\/li>\n<li>\n<p><strong>Two-factor authentication and sign-in through OpenID Connect.<\/strong> These are next on the roadmap.<\/p>\n<\/li>\n<li>\n<p><strong>Revocation checks for directory certificates<\/strong> (CRL, OCSP).<\/p>\n<\/li>\n<li>\n<p><strong>Running more than one instance.<\/strong> Bulk jobs and the sign-in counters live in memory.<\/p>\n<\/li>\n<li>\n<p><strong>Nested groups.<\/strong> Only direct membership counts.<\/p>\n<\/li>\n<\/ul>\n<h3>The common thread<\/h3>\n<p>Nearly every story above is the same story. Once a middleman with broad rights stands between people and the directory, the directory stops protecting against escalation, and each of its guarantees has to be rebuilt in the application. And what breaks is usually not the main rule but whatever quietly changes its inputs: a group\u2019s DN, the list of groups, the address of the directory.<\/p>\n<p>The code is on GitHub: <a href=\"https:\/\/github.com\/Nelsar\/Axia\" rel=\"noopener nofollow\">github.com\/Nelsar\/Axia<\/a>, Apache-2.0, version 0.2.1. I\u2019d especially like to know how Axia behaves with unusual schemas and on eDirectory. Please report vulnerabilities privately through the Security tab, and leave questions and criticism in the comments. The Russian version of this article is <a href=\"https:\/\/habr.com\/ru\/articles\/1096656\/\" rel=\"noopener nofollow\">here<\/a>.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1097632\/\">https:\/\/habr.com\/ru\/articles\/1097632\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>In Axia 0.1.0, a user allowed to edit accounts and move OUs could reset an administrator\u2019s password in four requests. None of the four broke the rule written to prevent exactly that.Axia is an open-source web console and REST API for Active Directory and OpenLDAP (.NET 10, React). It exists for a routine every company with a directory knows: create an account, reset a password, add someone to a group, disable a leaver. That work belongs with helpdesk, but helpdesk must not be able to promote itself, and every action must leave a trace.This is not a feature tour. It is five places where the obvious implementation turned out to be a hole or a bug, and what fixed each one. Most of them have nothing to do with Axia: anyone who builds access control on top of LDAP will run into them. The code is linked at the end.Why not directory ACLsThe textbook way to let helpdesk reset passwords is delegation: OU permissions in AD, olcAccess rules in OpenLDAP. The directory itself decides who may do what, and no middleman needs broad rights.In practice this has two problems. The first is complexity. \u201cSupport resets passwords in ou=people, except for members of cn=admins\u201d can be written in OpenLDAP, but such rules are hard to read and maintain, and a mistake in their order silently grants more than intended. The second is that the rules live on the directory tree while the questions are about people: \u201cwhat can Peters do?\u201d has to be pieced together from the ACLs of several OUs.Axia takes the other route, and its price is worth naming up front. The application binds to the directory with a service account that has broad rights, and decides on its own who may do what: directory groups map to roles, and roles to a set of 17 permissions.Directory groups mapped to rolesPermissions are evaluated on every request from the account\u2019s current groups, with a 30-second cache. Take someone out of a group, and half a minute later the rights are gone, even if their token is still valid.The more important consequence is another one. Since the service account makes every change, the directory no longer protects against privilege escalation. That is now the application\u2019s job, and nearly every trap below is about it. And whoever breaks into the application gets the directory, as far as the service account\u2019s rights reach. Weigh that before choosing the approach.How it comparesIt makes more sense to compare approaches than products: where permissions are checked and what you pay for it. Axia is one option here, and not the best one everywhere.Delegation in AD + RSATphpLDAPadmin, LAMAdaxes, ADManager PlusAxiaWho checks permissionsthe directory (ACLs)the directory (ACLs)the applicationthe applicationThe tool itself holds broad rightsnono, signs in as the useryes, a service accountyes, a service accountDirectoriesADany LDAPAD, Microsoft 365AD, OpenLDAP, eDirectoryAdmins protected from password resetsAdminSDHolder, built inif your ACLs say soits own delegation modeladminCount, protected groups, \u201cno one above yourself\u201dChange logDC security log, if auditing is onOpenLDAP accesslog, if configuredyesyes, refusals includedWhat it runs onWindows with RSATa web server with PHPa server and a licenceDocker or .NETMaturityyearsyearsyears, vendor supportMVP, version 0.2When you don\u2019t need Axia:AD only, administrators live in RSAT, delegation is in place. Nothing to change: AdminSDHolder already strips delegated rights from privileged accounts.OpenLDAP, and you write olcAccess with confidence. LAM gives you a web interface with no privileged middleman. That is strictly safer than Axia\u2019s model.You need request approval, workflows, auditor reports and vendor support. That is Adaxes and ADManager Plus territory.Axia makes sense when there are several directories or OpenLDAP is among them, when you want to grant rights through groups rather than ACLs, and when you need a log that shows refusals too. And when you need an API, not just a UI.Trap 1: moving an OU to become adminThe users.update permission says what change you may make, but not to whom. And every change reaches the directory as the service account, which can modify anyone. In an early version that was enough for the UserEditor role to reset an administrator\u2019s password and sign in as them, for users.delete to remove every administrator, and for groups.manage to add oneself to the group mapped to the Admin role.Hence the rule: you cannot act on anyone who holds more than you do. \u201cMore\u201d is measured in two places:In the application. The target\u2019s permissions must be a subset of the caller\u2019s.In the directory. Accounts and groups the directory itself treats as privileged (adminCount in AD, and the groups listed under DirectoryProtection) can be changed only by someone who holds every permission in the application. Without this, a domain admin whose groups mapped to no role was a plain User to Axia, and any editor outranked them.Nobody may change the service account: resetting its password through the application breaks the application at best, and at worst hands its rights to whoever chose the new password.The rule was there, and so were its tests. Then a hole turned up beside it. Roles are mapped to groups by DN: cn=axia-admins,ou=groups,dc=example,dc=org \u2192 Admin. But a DN is an address, not an identifier. A user with orgunits.manage and users.update needed four steps:Move the OU that holds the administrators\u2019 group. The group\u2019s DN changes, and the mapping to Admin stops matching.On the next request, the administrator is a plain User to the application and can be outranked.Reset their password.Move the OU back. Everything looks as before, except that the administrator\u2019s password is now known.No single step broke the rule. The hole was in the first public release, 0.1.0, and closed in 0.2.0. The fix is to judge an OU by what it contains. Renaming or moving an OU changes the address of its whole subtree, so everything in that subtree is checked:var subtree = await _ldapService.InspectSubtreeAsync(unit.Value);if (subtree.HoldsServiceAccount)    throw Refuse(unit.Value, &#171;it holds the service account&#187;, &#8230;);var held = await CallerPermissionsAsync(cancellationToken);if ((subtree.HoldsPrivileged || holdsProtectedGroup) &amp;&amp; !HoldsEverything(held))    throw Refuse(unit.Value, &#171;it holds entries privileged in the directory&#187;, &#8230;);\/\/ Mappings by DN only: a mapping by name matches wherever the group is, so moving it\/\/ changes nothing about it.var roles = (await _roleMappings.GetEffectiveMappingsAsync(_actingUser.ProviderId))    .Where(mapping =&gt; !mapping.IdentifiesGroupByNameOnly                      &amp;&amp; DistinguishedName.TryCreate(mapping.GroupName, out var group)                      &amp;&amp; group!.IsWithin(unit))    .Select(mapping =&gt; mapping.RoleName)    .ToHashSet(StringComparer.OrdinalIgnoreCase);var conferred = await _rolePermissions.GetPermissionsForRolesAsync(roles, cancellationToken);EnsureCallerHolds(held, conferred, unit.Value);Deleting an OU needs no such check: the directory will not delete one that isn\u2019t empty. Computer accounts follow the same rules, and only a full administrator can change a domain controller\u2019s account.Every refusal is written to the audit log together with who tried:A UserEditor refused a password reset on an administrator, in the audit logBefore 0.2.0 the log missed the most common refusal of all: a request with no permission for the action at all, turned away at the API\u2019s entrance before any business logic ran. That one is recorded now too.The lesson holds without Axia: if permissions hang on DNs, any operation that changes a DN, a rename or a move, is an operation on permissions. It deserves the same scrutiny as a change to group membership.Trap 2: the memberOf that isn\u2019t thereTo check permissions you need the account\u2019s groups. The simplest source is the memberOf attribute, and at first Axia read nothing else. In AD that works: memberOf is computed there and always present. OpenLDAP has no memberOf until the memberof overlay is enabled. A default installation doesn\u2019t load it, and where it is loaded it is often configured for groupOfNames and blind to groupOfUniqueNames.The consequences were asymmetric. The administrator signed in as a plain User: a safe failure that gets noticed on day one. But the same empty list meant nobody was in a protected group. The \u201cdon\u2019t touch the privileged\u201d rule fired for no one, and anyone allowed to reset passwords could reset the administrator\u2019s. That half goes unnoticed until someone uses it.Now groups are searched from the other side, as the groups that name the account, in all three forms directories use to store membership:internal static string GroupMembershipFilter(string distinguishedName, string? uid){    var dn = EscapeLdapFilterValue(distinguishedName);    return &#171;(|&#187;        + $&#187;(&amp;(objectClass=groupOfNames)(member={dn}))&#187;        + $&#187;(&amp;(objectClass=group)(member={dn}))&#187;        + $&#187;(&amp;(objectClass=groupOfUniqueNames)(uniqueMember={dn}))&#187;        + (string.IsNullOrWhiteSpace(uid)            ? string.Empty            : $&#187;(&amp;(objectClass=posixGroup)(memberUid={EscapeLdapFilterValue(uid)}))&#187;)        + &#171;)&#187;;}The result is merged with memberOf, not substituted for it. The DN is escaped: an entry\u2019s name can contain parentheses or an asterisk, and unescaped they would end the filter early or widen it to other people\u2019s groups. Nested groups are not expanded, just as memberOf doesn\u2019t expand them.The story has a sequel. Searching for groups is one more query per account. That is fine for sign-in and permission checks, but not for a list of thousands of users, so the list reads memberOf only. The list is cached, and the user\u2019s detail page was served from the same cache. So in a directory without memberOf, the page said \u201cnot a member of any group\u201d for people who were. Permissions were unaffected, since the check takes its own path, but the administrator was shown something untrue. In 0.2.0 the detail page re-reads the account&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-499698","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/499698","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=499698"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/499698\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=499698"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=499698"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=499698"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}