DEV Community

DarkEdges
DarkEdges

Posted on Edited on

Matching a directory entry to an Entra token, without turning on sync

Parts 1 and 2 showed how an agent gets a token for a user. This part answers a different question: is the directory entry
for that user the same person the token describes? In a real estate, that check is what a sync engine is trusted to keep true.
We did not want to turn on a real sync, so we built an emulation that is just enough to test the match.

What we were trying to prove

Entra Connect and Cloud Sync need a Windows server, and Microsoft does not support a Samba domain controller as a source. We
had a home tenant that was already hybrid with a stale sync, so we did not touch that sync. We added cloud-created users beside
the existing ones, and we wrote the directory-side data ourselves.

The question was narrow. Given an Active Directory user and the ID token Entra issues for that user, do the identifiers agree?
Four things had to match: the directory identity, the names, the mail address and whether the account is enabled.

Entra will not let us write every field. The real onPremisesSecurityIdentifier and the onprem_sid claim are controlled by
Entra, so we cannot fake them. We can write onPremisesImmutableId, which is the standard link between a directory object and
a cloud object, and we can write directory extension attributes, which can be emitted as claims. Those two carry the emulation.

The directory: a Samba domain controller

The domain controller runs Samba 4 in an unprivileged container, built from compose/Dockerfile.samba. It is started with the
ad profile:

cd compose
make ad-up
Enter fullscreen mode Exit fullscreen mode

On first start, the entrypoint provisions the realm PING.DARKEDGES.COM into a volume called samba-data, then seeds users from
compose/samba/users.json:

[
  { "sam": "ada",   "given": "Ada",   "sn": "Lovelace", "mail": "ada@ping.darkedges.com" },
  { "sam": "grace", "given": "Grace", "sn": "Hopper",   "mail": "grace@ping.darkedges.com" },
  { "sam": "alan",  "given": "Alan",  "sn": "Turing",   "mail": "alan@ping.darkedges.com" },
  { "sam": "nirving", "given": "Nicholas", "sn": "Irving", "display": "Nicholas L. Irving" }
]
Enter fullscreen mode Exit fullscreen mode

Three things went wrong on the way to a working domain controller, and each is handled in the entrypoint:

  • Missing VFS module. Samba's xattr-based ACLs need samba-vfs-modules. Without it the file system refused the attributes.
  • ACL storage. Docker's volume does not give the ACL attribute the default name Samba expects, so the entrypoint sets acl_xattr:security_acl_name = user.NTACL.
  • LDAPS key permissions. Samba runs as an unprivileged user and cannot read the mounted key, so the entrypoint copies it into the Samba private directory with mode 0600. The original stays read-only.

The LDAPS certificate comes from the same local CA as the rest of the stack. It has its own names, dc1.ping.darkedges.com
and localhost, so the shared server certificate does not change. A quick check from the host confirms the server is up:

ldapsearch -H ldaps://127.0.0.1:1636 -x -b '' -s base '(objectClass=*)' namingContexts
Enter fullscreen mode Exit fullscreen mode

The output lists DC=ping,DC=darkedges,DC=com among the naming contexts. If it does not, the realm was not provisioned.

The identifiers that link the two worlds

Every AD user has a objectGUID, a 16-byte value that does not change when the user is renamed. That is the key we link on.
Two forms of it matter, and mixing them up is the most common mistake here:

  • In the directory's binary form, the first three fields are little-endian. The text form people read in Windows tools is mixed-endian for the same bytes.
  • The Entra onPremisesImmutableId is the base64 of the binary GUID.

compose/ad/codecs.mjs holds the conversions and tests them against known values:

import { guidToString, immutableIdOf, stringToGuid } from './codecs.mjs';

const bytes = Buffer.from('a6b3f1c2d4e5f60718293a4b5c6d7e8f', 'hex'); // example bytes, not a real user
console.log(guidToString(bytes));        // text form, mixed-endian
console.log(immutableIdOf(bytes));       // what Entra stores as onPremisesImmutableId
console.log(stringToGuid(guidToString(bytes)).equals(bytes)); // true: the round trip is lossless
Enter fullscreen mode Exit fullscreen mode

The security identifier (objectSid) gets the same treatment through sidToString. And a disabled account is one whose
userAccountControl has bit 0x2 set, which isAccountEnabled checks.

The emulated sync

compose/ad/sync.mjs is a one-shot upsert. It reads the users over LDAP, computes what each cloud user should look like, and
writes it. Running it twice changes nothing the second time. It never deletes: a user removed from the directory is marked
disabled in Entra, not removed, so the audit history stays intact.

The mapping is written to Entra as follows:

Directory field Entra field
objectGUID onPremisesImmutableId (base64)
displayName, givenName, sn displayName, givenName, surname
mail mail
objectSid, sAMAccountName, distinguishedName extension attributes adSid, adSam, adDn, and adGuid for the GUID as text
userAccountControl bit 0x2 accountEnabled

Extension attributes live on the Broker RP application, and their full names are
extension_<appIdNoHyphens>_adGuid and so on. The sync creates them once and then reuses them.

The target is chosen with a flag:

make ad-sync TARGET=mock          # writes .secrets/mock-directory.json for the mock Entra
make ad-sync TARGET=real SYNC_ARGS=--dry-run   # shows what would change, without writing
make ad-sync TARGET=real          # creates or updates the cloud users in your tenant
Enter fullscreen mode Exit fullscreen mode

Passwords for the new cloud users are generated and stored only in the gitignored .secrets/ad-users.json. The sync never prints
them.

Adopting a user who already exists

One user, nirving, already existed in the home tenant from an earlier sign-in. We did not want a duplicate. The file
compose/ad/mappings.json tells the sync to adopt an existing object by its Entra object id:

{
  "nirving": {
    "entraOid": "<existing object id>",
    "entraUpn": "nirving@ping.darkedges.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

The sync links the directory entry to that object, sets its onPremisesImmutableId and its extension attributes, and leaves
its sign-in history alone. Without the mapping, the sync would have created a second account with the same mail address.

The match

compose/ad/compare.mjs contains pure functions. The card in the demo console and the CLI both call them, so they cannot
disagree. The match links the token to the directory by GUID and then checks each field:

guid           token adGuid        == directory objectGUID          match
sid            token adSid         == directory objectSid           match
sam            token adSam         == directory sAMAccountName      match
dn             token adDn          == directory distinguishedName   match
upn            token preferred_username == directory userPrincipalName  match
mail           token email         == directory mail                match
displayName    token name          == directory displayName         match
given/family   token given_name, family_name == givenName, sn        match
enabled        directory userAccountControl bit 0x2 clear            match
Enter fullscreen mode Exit fullscreen mode

Required fields are GUID, SID, SAM name, DN, UPN and enabled. A missing required field is a failure, not a warning. Here is the
shape of a run where everything agrees. The values are illustrative:

linked by objectGUID: yes
  guid          match
  sid           match
  sam           match
  dn            match
  upn           match   nirving@ping.darkedges.com
  mail          match
  displayName   match
  enabled       match
result: ok
Enter fullscreen mode Exit fullscreen mode

Run it against the real tenant with make ad-verify, which reads the Graph entry for the user and compares it with Samba.

Trying to break it

A check that only passes is not much of a test. The end-to-end test in compose/e2e/ad.mjs deliberately damages the directory
and asserts that each damage is reported. It does three things:

  1. It renames a user's display name. The test expects a displayName mismatch problem.
  2. It changes the UPN to an address on another domain. The test expects a userPrincipalName mismatch.
  3. It disables the account by setting userAccountControl to 514. The test expects a message that the account is enabled in AD while Entra says otherwise.

After each change, the test restores the original value in a finally block, and then checks that the comparison is clean
again. Finally it passes a foreign GUID into the token and asserts the link fails. A different user must never be confused
with this one.

The design of these tests is what matters: the comparison names the field that changed, not just "mismatch", so a failing
run points at the attribute to look at.

What the emulation does not prove

The emulation proves that the identifiers can be linked and compared. It does not prove that Entra would run a real sync the
same way. Real sync also handles soft-deleted objects, attribute scopes, and conflicts that this emulation never creates. We
state that limit plainly in the demo, because a green check here is about the match, not about Entra Connect.

The next part covers what the real tenant did that the mock did not.

Top comments (0)