-
Notifications
You must be signed in to change notification settings - Fork 0
Recipe LDAP
Goal: read the accounts and attributes of an LDAP / Active Directory directory (a "pull" directory source), project them onto Kengela's normalized
DirectoryProfile, then map them onto the tenant's internal roles.
Every symbol below has been verified in the source code. The design points that might surprise
(two DirectoryProfile types, the adapter's lack of fetchProfile, the status of ldapts,
sAMAccountName not consumed) are affirmed and explained at the end of the page (§7 "Design
facts"), not left hanging.
Kengela describes a directory source with a minimal port, in @kengela/contracts:
// @kengela/contracts
export interface DirectorySourcePort {
fetchProfile(raw: unknown, tenantId: TenantId): Promise<DirectoryProfile>;
}The DirectoryProfile of the contracts port is deliberately narrowed:
// @kengela/contracts — forme de convergence côté application
export interface DirectoryProfile {
readonly externalId: string;
readonly email?: string;
readonly displayName?: string;
readonly groups: readonly string[];
readonly attributes: Readonly<Record<string, unknown>>;
readonly active: boolean;
readonly source: 'oidc' | 'scim' | 'saml' | 'ldap' | 'graph' | 'google';
}CAUTION — there are two types named
DirectoryProfile(see §7). The one from@kengela/iam-mapping(produced byprofileFromLdap) does not have the same shape as the port's. Switching from one to the other is done bytoContractsProfile(§3).
@kengela/adapter-directory-ldap provides the class LdapDirectorySource (real name). It
does nothing but speak LDAP:
- binds over LDAP(S) (
bind), traverses the directory via paged search (Paged Results Control) under abaseDN, unbinds (unbindguaranteed even on failure); - returns normalized
LdapEntryPartsentries (DN + attributes as strings, binaries such asobjectGUIDin base64); - exposes a health-check (
checkConnection); - contains no role-mapping logic: the projection stays in the pure library
@kengela/iam-mapping.
Real methods of the class:
| Member | Real signature |
|---|---|
fetchEntries |
fetchEntries(filter?: string, options?: FetchEntriesOptions): Promise<readonly LdapEntryParts[]> |
checkConnection |
checkConnection(): Promise<boolean> |
static toProfiles |
toProfiles(entries, map?): readonly DirectoryProfile[] (iam-mapping profile)
|
static toRecords |
toRecords(entries, map?): readonly DirectoryRecord[] (profile + active)
|
There is no
fetchProfile(raw, tenantId)method onLdapDirectorySource: the class therefore does not directly implementDirectorySourcePort(see §3 and §7).
Repo doctrine: the port is an airlock, not a hideout. The adapter imports nothing from
ldapts into its contract; it describes exactly the 3 methods it uses (read-only, no directory
writes):
// @kengela/adapter-directory-ldap — surface étroite lue
export interface LdapClientLike {
bind(dn: string, password: string): Promise<void>;
search(baseDN: string, options: LdapSearchOptions): Promise<LdapSearchResult>;
unbind(): Promise<void>;
}
export type LdapClientFactory = () => LdapClientLike;The real Client of ldapts satisfies this interface structurally; so does an in-memory
fake (tests). Kengela therefore depends on LdapClientLike, not on the concrete ldapts type.
Status of ldapts in the package — verified in the adapter's package.json: ldapts is a
direct dependency ("ldapts": "^8.1.8" under dependencies), NOT a peerDependency
nor an optionalDependency. It is therefore installed transitively with the adapter; the
default client factory instantiates a real new Client(...) of ldapts with no additional
configuration.
npm install @kengela/adapter-directory-ldap
# ldapts est tiré automatiquement (dependency directe ^8.1.8) — aucune install séparée requise.@kengela/iam-mapping (the pure mapping library) is also a dependency of the adapter, and the
adapter re-exports its useful symbols (profileFromLdap, accountActiveFromLdap, types) to
avoid a double dependency.
Real constructor options (extracted from LdapConnectionConfig):
import { LdapDirectorySource, type LdapConnectionConfig } from '@kengela/adapter-directory-ldap';
const config: LdapConnectionConfig = {
url: 'ldaps://dc.corp.local:636', // LDAPS recommandé ; ldap:// en dev seulement
bindDN: 'CN=svc-kengela,OU=Service,DC=corp,DC=local', // compte de service (lecture)
bindPassword: process.env.LDAP_BIND_PASSWORD!, // résolu depuis un coffre ; jamais loggé
baseDN: 'OU=Users,DC=corp,DC=local', // racine de recherche
// --- optionnels (sinon défauts Active Directory, cf. LDAP_SOURCE_DEFAULTS) ---
userFilter: '(&(objectCategory=person)(objectClass=user))', // défaut AD
attributes: ['*', 'memberOf'], // défaut AD
timeoutMs: 15_000, // défaut
tlsRejectUnauthorized: true, // défaut ; ne désactiver que pour un annuaire de test
pageSize: 200, // taille de page paginée
maxUsers: 1000, // plafond d'entrées par pull
};The bounds/defaults live in LDAP_SOURCE_DEFAULTS (exported): userFilter, attributes,
timeoutMs = 15000, pageSize = 200, maxUsers = 1000, tlsRejectUnauthorized = true.
The second argument LdapDirectorySourceOptions lets you inject a client factory
(clientFactory?: LdapClientFactory). Without injection, the source builds a real Client
of ldapts itself (verified LDAPS) from the config — this is the nominal case:
const source = new LdapDirectorySource(config); // clientFactory par défaut = ldapts Client réel
// Health-check avant tout pull :
if (!(await source.checkConnection())) {
throw new Error('Annuaire LDAP injoignable ou identifiants invalides');
}Useful for tests, a pool, or an alternative client. The factory returns something assignable to
LdapClientLike:
import { Client } from 'ldapts';
import type { LdapClientFactory } from '@kengela/adapter-directory-ldap';
const clientFactory: LdapClientFactory = () =>
new Client({ url: config.url, timeout: 15_000, tlsOptions: { rejectUnauthorized: true } });
const source = new LdapDirectorySource(config, { clientFactory });LdapDirectorySource does not implement DirectorySourcePort as-is: the port expects
fetchProfile(raw, tenantId) returning the contracts DirectoryProfile (with active,
source), whereas the source exposes a batch API (fetchEntries) and helpers that produce
the iam-mapping DirectoryProfile (with firstName/lastName/claims). This is a
design fact, not a gap (§7).
The iam-mapping → contracts bridge is not written by hand: @kengela/iam-mapping exports
the PURE function toContractsProfile(rich, { source, active }) which projects the rich
profile onto the minimal contracts shape (adding active/source, non-null externalId,
firstName/lastName folded into attributes, claims dropped). The port adapter then
reduces to three calls:
// profileFromLdap / accountActiveFromLdap : ré-exportés par l'adapter (SSoT iam-mapping).
import { profileFromLdap, accountActiveFromLdap } from '@kengela/adapter-directory-ldap';
import type { LdapEntryParts } from '@kengela/adapter-directory-ldap';
// toContractsProfile : depuis iam-mapping (l'adapter ne le ré-exporte pas).
import { toContractsProfile } from '@kengela/iam-mapping';
import type { DirectorySourcePort, DirectoryProfile, TenantId } from '@kengela/contracts';
class LdapDirectoryPort implements DirectorySourcePort {
async fetchProfile(raw: unknown, _tenantId: TenantId): Promise<DirectoryProfile> {
const entry = raw as LdapEntryParts; // le port reçoit une entrée normalisée
const rich = profileFromLdap(entry); // DirectoryProfile "iam-mapping" (riche)
return toContractsProfile(rich, { source: 'ldap', active: accountActiveFromLdap(entry) });
}
}
export const ldapPort: DirectorySourcePort = new LdapDirectoryPort();
toContractsProfileguarantees a non-nullexternalId(falls back to the email ifobjectGUIDis missing), omitsdisplayNamewhen empty (exactOptionalPropertyTypes) and foldsfirstName/lastNameintoattributes.activeandsourceare the two fields the rich profile does not carry; they are supplied explicitly here (accountActiveFromLdap+'ldap').toContractsProfileis imported from@kengela/iam-mapping— the adapter re-exportsprofileFromLdap/accountActiveFromLdapbut nottoContractsProfile.
import { LdapDirectorySource, profileFromLdap } from '@kengela/adapter-directory-ldap';
// (a) Lecture réseau : bind → search paginé → normalisation → unbind
const entries = await source.fetchEntries(); // readonly LdapEntryParts[]
// (b) Projection vers DirectoryProfile (iam-mapping) — 3 voies possibles :
// 1. helper statique batch :
const profiles = LdapDirectorySource.toProfiles(entries);
// 2. helper batch avec état d'activation (dé-provisioning) :
const records = LdapDirectorySource.toRecords(entries); // { profile, active }[]
// 3. unitaire :
const one = profileFromLdap(entries[0]);fetchEntries(filter?, options?) accepts an ad hoc LDAP filter and real FetchEntriesOptions:
attributes?, max?, scope? ('base' | 'one' | 'sub', default sub), attributeMap?
(LdapAttributeMap, attached to each entry for the projection).
profileFromLdap(e: LdapEntryParts) reads the attributes via LDAP_AD_ATTRIBUTE_DEFAULTS,
overridable one by one per tenant via e.attributeMap (LdapAttributeMap). Real defaults:
| Profile field | Default AD attribute |
|---|---|
email |
mail (fallback userPrincipalName) |
firstName |
givenName |
lastName |
sn |
displayName |
displayName (fallbacks cn, then givenName sn) |
externalId |
objectGUID (fallback: the DN) |
groups |
memberOf (each DN reduced to its CN) |
department / division / title
|
department / division / title
|
employeeNumber |
employeeNumber (fallback employeeID) |
officeLocation |
physicalDeliveryOfficeName |
manager |
manager (DN reduced to its CN — V2 debt: resolve to email) |
costCenter |
(no AD default; empty string) |
accountActiveFromLdap(e) reads userAccountControl: bit 0x2 (ACCOUNTDISABLE) → disabled
account; attribute absent (OpenLDAP) → considered active.
The mapping engine is pure and tenant-configurable (@kengela/iam-mapping):
import { evaluateMappings, type IdpMappingRule } from '@kengela/iam-mapping';
const rules: IdpMappingRule[] = [
{
id: 'rh-admins',
priority: 0,
all: [{ source: 'GROUP', op: 'in', value: ['Groupe RH', 'Domain Admins'] }],
assignRoleKeys: ['ADM'],
orgUnit: { by: 'code', value: 'RH' },
stopOnMatch: false,
},
{
id: 'valideurs',
priority: 10,
any: [{ source: 'ATTRIBUTE', key: 'title', op: 'contains', value: 'Manager' }],
assignRoleKeys: ['VAL'],
},
];
const result = evaluateMappings(profile, rules);
// result.roleKeys -> union des clés de rôle accordées (ex. ["ADM","VAL"])
// result.orgUnitDirectives -> directives d'unité par priorité
// result.matchedRuleIds -> ids des règles ayant matché (audit / dry-run)Conditions (MappingCondition): source = 'GROUP' | 'CLAIM' | 'ATTRIBUTE', op =
'equals' | 'iequals' | 'contains' | 'matches' | 'in' | 'present'. matches compiles a
ReDoS-bounded regex (fail-closed) via safeRegexTest. Deterministic evaluation: sorted by
(ascending priority, id), roles accumulated (union), stopOnMatch short-circuits.
Note:
GROUPrules testprofile.groups, which for LDAP are the CNs extracted from thememberOfDNs. So use the group's CN ("Groupe RH"), not its full DN.
-
memberOf: AD returns groups as full DNs (CN=Groupe RH,OU=...,DC=corp).profileFromLdapreduces each DN to its CN; it is that CN that feedsprofile.groupsand the rule engine.memberOfis requested explicitly by default (attributes: ['*','memberOf']). -
sAMAccountName: not inLDAP_AD_ATTRIBUTE_DEFAULTSand not consumed byprofileFromLdap(no "login" field inDirectoryProfile). The email comes frommail, with a fallback touserPrincipalName. If your directory has nomail, make sureUPNis set, or overrideemailviaLdapAttributeMap.sAMAccountNamenevertheless remains retrievable raw (viaattributes: ['sAMAccountName', ...]) if your application code needs it. -
Service Bind DN:
bindDNmust be a full DN (CN=svc-kengela,OU=Service,DC=corp,DC=local) of a read account;bindPasswordis resolved from a vault by the caller and is never logged (this module logs nothing). -
Deprovisioning: AD encodes deactivation in
userAccountControl(bit0x2). UsetoRecords()/accountActiveFromLdap()to propagateactive: false. -
objectGUID: binary → normalized to base64 by the adapter, and serves as a stableexternalId(immutable identifier, unlike the DN which moves if the OU changes). -
LDAPS:
tlsRejectUnauthorizedistrueby default; only set it tofalseagainst a test directory.
| ✅ Provided by Kengela | ✍️ To write on the application side |
|---|---|
LdapDirectorySource: bind + paged search + unbind, LdapEntryParts normalization |
The concrete bind config (LdapConnectionConfig): URL, bindDN, password (vault), baseDN, filters |
Default factory of a Client ldapts (verified LDAPS) |
An alternative concrete LDAP client (pool/fake), only if you do not use the default factory |
LdapClientLike (narrow surface), checkConnection
|
The DirectorySourcePort adapter (reshape iam-mapping → contracts: active, source, non-null externalId) |
profileFromLdap, accountActiveFromLdap, LDAP_AD_ATTRIBUTE_DEFAULTS, helpers toProfiles/toRecords
|
The per-tenant attribute map (LdapAttributeMap) if your schema is not standard AD |
evaluateMappings engine (pure, anti-ReDoS) + rule types |
The per-tenant mapping rules (IdpMappingRule[]) → role keys + org-chart units |
Safe regexes (safeRegexTest, SAFE_REGEX_LIMITS) |
The persistence (upsert users/roles), the pull orchestration and its scheduling |
-
Two distinct
DirectoryProfiletypes — by design. The one from@kengela/iam-mapping(return ofprofileFromLdap/toProfiles) is RICH:{ email, externalId, firstName, lastName, displayName, attributes, groups, claims }(email: string,externalId: string | null). The one from@kengela/contracts(return of the port) is MINIMAL and STABLE:{ externalId: string, email?, displayName?, groups, attributes, active, source }. They are not interchangeable; the projection from one to the other is done bytoContractsProfile(§3.4), a PURE function exported by@kengela/iam-mapping. So it is no longer "code to write": it is a library call. -
LdapDirectorySourcedoes NOT implementDirectorySourcePort— an assumed design fact. The class has nofetchProfile(raw, tenantId)method: its API is batch (fetchEntries(filter?, options?)+checkConnection()) with the static helperstoProfiles/toRecords. An LDAP pull reads thousands of entries in a single paged search (bind → search → unbind); exposing a per-userfetchProfilewould force one bind per user, contrary to LDAP's "batch" nature. The port adapter (§3.4) bridgesLdapEntryParts → contracts: the port typesraw: unknown, and for this sourcerawis anLdapEntryParts(a normalized entry already produced byfetchEntries). -
ldapts= DIRECT dependency, not peer nor optional. Verified in the adapter'spackage.json:"ldapts": "^8.1.8"underdependencies. No separate install required; the default factory instantiates a realnew Client(...)with no additional configuration. -
sAMAccountNameis NOT consumed byprofileFromLdap. Verified: it is not inLDAP_AD_ATTRIBUTE_DEFAULTS(whose real keys aremail,givenName,sn,displayName,objectGUID,memberOf,department,division,title,employeeNumber,physicalDeliveryOfficeName,manager;costCenter= empty string). There is no "login" field inDirectoryProfile: the email comes frommail(fallbackuserPrincipalName). If your application code needs it,sAMAccountNameremains retrievable raw by requesting it (attributes: ['sAMAccountName', ...]), but it populates no profile field. -
safe-regex.ts— exported symbols.safeRegexTest,compileSafeRegex,SAFE_REGEX_LIMITS,SafeRegexLimits.matchescompiles a bounded regex (fail-closed); the exact bounds live inSAFE_REGEX_LIMITS. -
manageras V2 debt.profileFromLdapreduces the manager's DN to its CN for lack of a second LDAP call; it is not resolved to an email (documented as-is in the source).
A single file that assembles all the functional code of the page: bind configuration,
health-check, paged pull, projection onto DirectoryProfile, mapping to roles, the
DirectorySourcePort adapter (via toContractsProfile) and the orchestration of a
ScimRepository sync.
import {
LdapDirectorySource,
profileFromLdap,
accountActiveFromLdap,
type LdapConnectionConfig,
type LdapEntryParts,
} from '@kengela/adapter-directory-ldap';
import { evaluateMappings, toContractsProfile, type IdpMappingRule } from '@kengela/iam-mapping';
import type {
DirectorySourcePort,
DirectoryProfile,
ScimRepository,
TenantId,
} from '@kengela/contracts';
// ── 1. Configuration de bind (résolue depuis la config tenant + coffre) ─────
const config: LdapConnectionConfig = {
url: 'ldaps://dc.corp.local:636', // LDAPS recommandé
bindDN: 'CN=svc-kengela,OU=Service,DC=corp,DC=local', // compte de lecture
bindPassword: process.env.LDAP_BIND_PASSWORD!, // coffre ; jamais loggé
baseDN: 'OU=Users,DC=corp,DC=local',
// Optionnels (sinon défauts AD via LDAP_SOURCE_DEFAULTS) :
userFilter: '(&(objectCategory=person)(objectClass=user))',
attributes: ['*', 'memberOf'],
timeoutMs: 15_000,
tlsRejectUnauthorized: true,
pageSize: 200,
maxUsers: 1000,
};
// clientFactory par défaut = vrai Client ldapts (LDAPS vérifié) construit depuis la config.
const source = new LdapDirectorySource(config);
// ── 2. Règles de mapping (par tenant) ───────────────────────────────────────
const rules: IdpMappingRule[] = [
{
id: 'rh-admins',
priority: 0,
all: [{ source: 'GROUP', op: 'in', value: ['Groupe RH', 'Domain Admins'] }],
assignRoleKeys: ['ADM'],
orgUnit: { by: 'code', value: 'RH' },
stopOnMatch: false,
},
{
id: 'valideurs',
priority: 10,
any: [{ source: 'ATTRIBUTE', key: 'title', op: 'contains', value: 'Manager' }],
assignRoleKeys: ['VAL'],
},
];
// ── 3. Adaptateur DirectorySourcePort (reshape via toContractsProfile) ──────
class LdapDirectoryPort implements DirectorySourcePort {
async fetchProfile(raw: unknown, _tenantId: TenantId): Promise<DirectoryProfile> {
const entry = raw as LdapEntryParts; // une entrée normalisée produite par fetchEntries
const rich = profileFromLdap(entry); // DirectoryProfile riche (iam-mapping)
return toContractsProfile(rich, { source: 'ldap', active: accountActiveFromLdap(entry) });
}
}
export const ldapPort: DirectorySourcePort = new LdapDirectoryPort();
// ── 4. Orchestration d'un pull complet ──────────────────────────────────────
export async function syncLdap(
tenantId: TenantId,
scimRepository: ScimRepository,
): Promise<{ synced: number; deactivated: number }> {
// (a) Health-check avant tout pull.
if (!(await source.checkConnection())) {
throw new Error('Annuaire LDAP injoignable ou identifiants invalides');
}
// (b) Lecture réseau : bind → search paginé → normalisation → unbind.
const entries = await source.fetchEntries(); // readonly LdapEntryParts[]
// (c) Projection + activation en une passe (dé-provisioning).
const records = LdapDirectorySource.toRecords(entries); // { profile, active }[]
let synced = 0;
let deactivated = 0;
for (let i = 0; i < entries.length; i += 1) {
const rich = records[i].profile; // DirectoryProfile riche
const active = records[i].active; // userAccountControl bit 0x2
// (d) Mapping vers les rôles internes (moteur pur, configurable par tenant).
const result = evaluateMappings(rich, rules);
// → result.roleKeys / result.orgUnitDirectives / result.matchedRuleIds
// (e) Réconciliation : projection RICHE → MINIMAL (contracts), puis upsert.
const profile = toContractsProfile(rich, { source: 'ldap', active });
const { id } = await scimRepository.upsertUserByEmail(tenantId, profile);
synced += 1;
if (!active) {
await scimRepository.deactivateUser(tenantId, id);
deactivated += 1;
}
// … appliquer result.roleKeys + result.orgUnitDirectives via vos repos (grants + rattachement).
void result;
}
return { synced, deactivated };
}🇬🇧 English
- Home
- Getting started
- Architecture
- Authorization
- Authentication
- Identity federation
- Compliance & PII
- NestJS integration
- Developing an adapter
- Security
Recipes
- NestJS + native + Prisma
- better-auth
- SCIM / Entra
- LDAP / AD
- RBAC / ABAC
- PII / GDPR
- Combo: better-auth + PII
- Combo: SCIM/Entra + authz
- Combo: full stack
🇫🇷 Français
- Accueil
- Prise en main
- Architecture
- Autorisation
- Authentification
- Fédération d'identité
- Conformité & PII
- Intégration NestJS
- Développer un adapter
- Sécurité
Recettes