Skip to content

Concepts

Directory attribute normalization

SCIM defines a core schema, and almost nobody sends only the core schema. Every provider adds an extension, every HR system has its own field names, and every customer has configured at least one attribute that exists nowhere else. Normalisation is the layer that turns that into a record with the same shape every time.

How far the names spread

The SCIM core schema covers a useful minimum: a user name, a set of emails, an active flag, a display name. Beyond that, providers use an enterprise extension whose fields are broadly agreed but inconsistently populated, and then each one adds its own namespace on top.

HR systems widen the gap further, because they were designed to describe employment rather than access. A field like manager can arrive as an identifier, an email address, a display name or a nested object, depending on the source — and all four are reasonable ways to express the same fact.

The result is that writing your own integration against one customer produces code that fails on the second. This is the same problem SAML attribute mapping has on the authentication side, arriving through a different door.

Normalised fieldArrives asNotes
emailsemails array, userName, or a primary email extension fieldThe primary flag is not always set; the first entry is not always primary.
first_namename.givenName, or a flat givenNameSome HR sources send only a full name and expect you to split it.
last_namename.familyName, sn, or surnameThe LDAP heritage again.
job_titletitle, or an enterprise extension jobTitlePopulated by HR systems far more reliably than by identity providers.
stateactive boolean, or a status stringA status of Terminated and active: false mean the same thing.
custom_attributesEverything else, under a vendor namespaceWhere department, cost centre and employee number usually live.

What a normalised record looks like

Standard fields are lifted into named properties; everything else is preserved under custom_attributes with its values coerced to strings, so a handler never has to branch on whether a number arrived as 4812 or as the string 4812. Nothing is discarded — an attribute the platform does not recognise is still there.

directory_user.json
1{
2 "object": "directory_user",
3 "id": "directory_user_01HQ8ZK3M4N5P6R7S8T9V0W1X2",
4 "directory_id": "directory_01HQ8ZK3M4N5P6R7S8T9V0W1X2",
5 "idp_id": "00u1a0ufowBJlzPlk357",
6 "emails": [{ "primary": true, "value": "avery@foo-corp.example" }],
7 "first_name": "Avery",
8 "last_name": "Lindqvist",
9 "job_title": "Principal Engineer",
10 "state": "active",
11 "groups": [{ "id": "directory_group_01HQ8ZK3M4N5P6R7S8T9V0W1X2", "name": "Engineering" }],
12 "custom_attributes": {
13 "department": "Platform",
14 "employee_number": "4812",
15 "cost_center": "CC-2200",
16 "manager_email": "morgan@foo-corp.example"
17 }
18}

Mapping an attribute your product needs

Anything specific to your application — a licence tier, a site code, a regulatory flag — needs an explicit mapping per directory, because the source field name is different for every customer and no amount of normalisation can guess it.

  1. 1Connect a test directory and read one user with the API. Look at custom_attributes rather than the customer's documentation; what is sent and what is documented are frequently different.
  2. 2Note the exact key, including case and any vendor namespace prefix.
  3. 3Add the mapping on the directory in the dashboard, under identity provider attributes, so the value arrives as a named field.
  4. 4Write the consuming code to treat the field as optional, with a defined behaviour when it is absent. It will be absent for the next customer.
  5. 5Decide what happens when the value is present but unrecognised — an unexpected licence tier should log and fall back, not throw inside a webhook handler.

Attributes are informative, not authoritative. A department field says what the customer's HR system believes; it does not by itself decide what somebody may do in your application. Map attributes to your own model and check that, so a change of wording at the source cannot alter access.

Reading attributes defensively

The failure that costs a weekend is not a missing attribute, it is a handler that assumed one. A customer restructures their directory, an optional field disappears for four thousand users at once, and every delivery starts failing — which pauses the endpoint, which stops the events you do care about.

sync.ts
1function toLocalUser(directoryUser) {
2 const attrs = directoryUser.custom_attributes ?? {};
3 const primary = directoryUser.emails?.find((e) => e.primary) ?? directoryUser.emails?.[0];
4
5 return {
6 idpId: directoryUser.idp_id,
7 email: primary?.value ?? null,
8 name: [directoryUser.first_name, directoryUser.last_name].filter(Boolean).join(" ") || null,
9 // Eslesmemis alanlar her zaman opsiyoneldir.
10 department: attrs.department ?? null,
11 costCenter: attrs.cost_center ?? null,
12 active: directoryUser.state === "active",
13 };
14}