BetterIAM

LDAP gateway

Serve each organization's people and groups over read-only LDAPv3, with password and API key binds that go through Better IAM sign-in.

Plenty of software only speaks LDAP: VPN concentrators, NAS devices, CI servers, wikis, printers, older line-of-business applications. They expect a directory they can search for people and groups and bind against to check a password. Without one, an organization runs a second directory next to its identity system and keeps the two in sync, and every gap between them is a password that did not change or a leaver who can still sign in to the VPN.

The LDAP gateway removes the second directory. @better-iam/ldap is a read-only LDAPv3 server that you run next to your deployment. Each organization publishes its directory under a base DN of its own; a bind is a Better IAM sign-in, and a search is authorized as the account that bound. People, groups and memberships come straight from Better IAM, so a disabled account or a removed membership disappears from LDAP at once.

Who configures it. Your team runs the gateway process and gives it a TLS certificate. An organization's administrator turns it on for the organization and chooses what it publishes. The operator of each LDAP application points it at the gateway with a base DN and, usually, a service account's credentials.

Run the gateway

ldap.ts
import { readFileSync } from 'node:fs';
import { createLdapServer } from '@better-iam/ldap';
import { iam } from './iam';

const ldap = createLdapServer({
  iam,
  tls: { key: readFileSync('ldap.key'), cert: readFileSync('ldap.crt') }, // LDAPS; leave out for plain LDAP
});
await ldap.listen(636, '0.0.0.0');

Prop

Type

listen(port, host) defaults to port 636 with tls and 389 without, on 127.0.0.1; pass '0.0.0.0' to accept connections from other machines. It resolves with the bound address. ldap.close() tells connected clients the server is shutting down and signs out the sessions the gateway created, and ldap.connections counts live connections for metrics. Each connection handles its requests one at a time, in order.

The gateway needs no settings of its own in betterIam(): it reads each organization's settings through iam.ldap and the ldap API group. The package also exports its BER codec, DN and filter helpers, and client-side message encoders, for tools and tests.

Publish an organization's directory

Administrators with iam:ldap:manage turn the gateway on for their organization with ldap.updateSettings. Fields left out keep their values:

await iam.api.ldap.updateSettings(admin, {
  tenantId,
  enabled: true,
  baseDn: 'dc=acme,dc=com',
  uid: 'localPart',
  attributes: ['title', 'costCenter'], // declared identity attributes to publish; none by default
  groups: 'selected',
  groupIds: [vpnUsers.id, engineering.id],
});

Prop

Type

ldap.getSettings (iam:ldap:read) reads the settings. Published attributes must be declared in permissions.identityAttributes, with names that start with a letter and use letters, digits and - (INVALID_INPUT otherwise). A group of another organization is NOT_FOUND.

The directory tree

dc=acme,dc=com                          (organization)
├─ ou=people
│  └─ uid=alice@acme.com                (inetOrgPerson)
├─ ou=groups
│  └─ cn=Engineering                    (groupOfNames)
└─ ou=services
   └─ cn=vpn                            (account)
EntryObject classesAttributes
The base DNdcObject and organization for a dc= base, organizationalUnit for ou=, otherwise organizationIts naming attribute, and o with the organization's name (unless the base is o=)
uid={uid},ou=people,{baseDn}person, organizationalPerson, inetOrgPersonuid, cn (the name, else the email), sn (the last word of the name), givenName (the first word of a name of two or more), displayName, mail, memberOf, and the published attributes
cn={name},ou=groups,{baseDn}groupOfNamescn, description, and member (the DNs of people and listed service accounts)
cn={name},ou=services,{baseDn}account, applicationProcesscn, uid (the entry's name), description (Service account or AI agent), memberOf

Every entry also carries the operational attribute entryDN, and people, groups and services carry their Better IAM ID as entryUUID. Operational attributes are returned for + or by name. A published attribute with several values becomes a multi-valued LDAP attribute; one whose name clashes with a standard attribute above is left out.

  • Only what is live. Only active, unexpired accounts and live group memberships appear, so offboarding someone or ending a temporary membership takes them out of LDAP at once.
  • Unique names. Two service accounts or groups with the same name are told apart by ID: the later one is named cn={id}.
  • Services. ou=services lists service accounts and agents only with includeServiceAccounts.

Binding

Applications bind in one of two ways, always with a simple bind:

  • As a person: uid={uid},ou=people,{baseDn} with their password. The bind is a Better IAM sign-in, so rate limits, lockouts, allowed sign-in methods, IP rules and sign-in alerts apply, and the client's address is recorded (with the user agent better-iam-ldap). The gateway makes exactly one sign-in attempt per bind, and signs the session out when the connection ends or binds again.
  • As a service account or agent: cn={name},ou=services,{baseDn} with one of its API keys as the password. The key must belong to that account and organization, and it is checked like any API key. The name is matched without regard to case; when two accounts share a name, bind with cn={id}.

People who use MFA

LDAP has one password field, so people who must use MFA append the current six-digit authenticator code to their password (hunter2 followed by 123456 is hunter2123456). People whose MFA is required but who have no authenticator app enrolled cannot bind, since LDAP has no way to ask for a passkey. With mfaSuffix: 'never', people who must use MFA cannot bind at all; applications then bind as a service account. See MFA.

A bind with an empty name and password is anonymous and reads only the root DSE, which advertises LDAPv3, the WhoAmI extension and the paged results control (and, once bound, the base DN as namingContexts). A name with an empty password is refused (unwillingToPerform) rather than treated as an anonymous bind, a common source of LDAP login bypasses. SASL binds and LDAP versions other than 3 are not supported.

What a bound account sees

Searches are authorized as the bound account. With iam:ldap:read on iam/ldap/directory, it reads the whole published directory, audited as ldap:directory:read with the number of people and groups. This is the permission for the VPN's or wiki's service account:

Let a service account read the directory
{ "effect": "allow", "actions": ["iam:ldap:read"], "resources": ["iam/ldap/directory"] }

Without it, an account sees only its own entry and its own groups, each with only itself as a member. That is enough for applications that bind as the person and then read their groups. The platform override does not read an organization's directory, and sessions and credentials of another organization are refused.

A connection sees only the tree of the organization it bound to: any other base is noSuchObject. Each connection fetches a snapshot of what it may read and reuses it for cacheMs.

Supported operations

  • Search with base, one-level and subtree scopes; every RFC 4511 filter type (and, or, not, equality, substrings, >=, <=, approximate, presence, extensible); attribute selection with *, +, names and 1.1; types only; size limits; and the paged results control of RFC 2696. Values compare without regard to case. Extensible matches support only the default equality rule on a named attribute; others match nothing. Filters deeper than 32 levels or with more than 256 terms close the connection with a protocol error.
  • Compare, on any entry of the bound organization.
  • WhoAmI (RFC 4532), which answers dn: followed by the bound DN.
  • Unbind, which closes the connection and signs out a person's session.

Add, modify, delete and rename are refused (unwillingToPerform): the directory is managed in Better IAM. Other extended operations, StartTLS among them, answer protocolError.

Result codeWhen
invalidCredentialsA wrong password, key or code; an unknown or inactive account; binds of that kind turned off; MFA that LDAP cannot satisfy
confidentialityRequiredA bind without TLS while requireTls is on, from anywhere but loopback
unwillingToPerformA name with an empty password, too many attempts (rate limited), or a write
authMethodNotSupportedA SASL bind
insufficientAccessRightsA search or compare before binding, or a search whose directory read is refused (the gateway was turned off, the session ended)
noSuchObjectA base or entry outside the bound organization's tree, or one that does not exist
sizeLimitExceededMore matches than the size limit (or maxResults), without paging

Test a setup

With OpenLDAP's command-line tools, bind as the service account and look for the members of a group:

ldapsearch -H ldaps://ldap.example.com \
  -D "cn=vpn,ou=services,dc=acme,dc=com" -w "$VPN_API_KEY" \
  -b "ou=people,dc=acme,dc=com" "(memberOf=cn=VPN users,ou=groups,dc=acme,dc=com)" mail

API

MethodAccess
getSettingsiam:ldap:read on iam/ldap/settings
updateSettingsiam:ldap:manage on iam/ldap/settings
directoryA credential of the organization; iam:ldap:read on iam/ldap/directory for everyone, otherwise only yourself

directory is what the gateway serves searches from, called as the account that bound. It answers FEATURE_DISABLED while the gateway is off. iam.ldap.tenantForBase and iam.ldap.resolveBindName are what the gateway uses before anyone is bound: they map a base DN to its organization and a bind name to an account, and return no secrets. Settings changes are audited as iam:ldap:manage.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page