Projects · Entra ID

End-to-End IAM: From HR to Entra ID with Elytra and Ascendra

By Andreas Krisby 2026-07-24 5 min read

Background

end-to-end-iam is a side project where I explore how a modern IAM landscape can be built. The project revolves around a fictional company, Legatus Corp, and consists of two separate systems.

  • Elytra: an HR system that acts as the source of truth for employment data.
  • Ascendra: an IGA platform (Identity Governance & Administration) that handles identities, roles, provisioning, SSO connections, and audit.

I set the direction for the project: that the systems should be fully separate, that Convex would be the platform, and that the IGA system should connect to a real Entra ID tenant. I set up the app registration, permissions, and client secret in Entra ID myself. The code implementation was built with AI-assisted development.

It’s the same problem every organization with more than a handful of employees has to solve. When someone joins, the right accounts need to be created automatically. When someone changes role, access needs to be updated. When someone leaves, access needs to disappear quickly and safely. That’s exactly what the Joiner-Mover-Leaver flow (JML) this project models.

Initially, the connection to the identity provider was fully simulated. “Test connection” just wrote a line to the audit log regardless of what was actually configured. No Entra ID, no Graph calls, and no real provisioning. I wanted to see how the solution would hold up against a real directory.

What I Built

Architecture

Elytra and Ascendra are deliberately fully separate systems.

  • Separate Convex deployments
  • No shared database
  • No direct communication between the systems

The only connection is an append-only outbox. Elytra publishes:

  • employee.created
  • employee.updated
  • employee.activated
  • employee.terminated

Ascendra polls the events with a cursor and builds up its own identity model. This mirrors how many larger organizations separate HR from their IGA platform.

Elytra – Onboard employee, the first step of the JML flow

Provisioning to Entra ID

What was missing was the next step. I didn’t just want to create identities inside Ascendra — I wanted to actually create, update, and disable real users in Entra ID.

My first thought was SCIM. That turned out to be the wrong path. When Entra ID is the target directory, there’s no inbound SCIM endpoint to provision against. SCIM is instead used by Entra when it provisions users onward to other SaaS services.

The correct solution turned out to be the Microsoft Graph API with application permissions and the Client Credentials flow. The app registration uses:

  • Single Tenant
  • No Redirect URI
  • User.ReadWrite.All
  • Organization.Read.All
  • Application Permissions
  • Admin Consent
  • Client Secret

What I Tested

  • Verification against a real Entra tenant via Microsoft Graph
  • Automatic onboarding
  • Updating users
  • Offboarding
  • Retry of failed provisioning attempts
  • Manual resync
  • Periodic reconciliation between HR and Entra

Results

Provisioning is now real all the way through. When an HR event is created in Elytra, it materializes in Ascendra, which then creates, updates, or disables a real account in Entra ID.

In addition to the event-driven provisioning, there is also:

  • Reconciliation every 15 minutes
  • Retry of failed Graph calls
  • SLA warnings if onboarding or offboarding takes too long

Auth0 and the generic SCIM integration are still simulated, since they weren’t needed for this particular part of the project.

Entra ID – Audit log showing Ascendra creating and updating a real user account

Challenges

Things only got interesting once the integration became real.

Resync re-enabled a terminated user

Resync is a button in Ascendra that manually forces a new sync against Entra ID — for example if a previous sync failed. It doesn’t read anything new from the HR system; it takes what Ascendra already knows about the identity and sends it to Entra again via Graph.

The most serious bug: the code behind Resync always sent accountEnabled = true to Graph, regardless of whether the person was still employed. Concretely, this is what happened: an employee was terminated, and the account was correctly disabled in Entra. I clicked Resync again on the same person, just to test it, and the account was re-enabled in Entra — even though the HR system still showed the person as terminated.

The fix was to always derive accountEnabled from the HR system’s current employment status, no matter which function makes the Graph call. This was a bug that could have restored a former employee’s access with nothing more than a single extra button click.

UPN and email are not the same thing

My tenant only had its default domain verified. The fix was to use the tenant’s verified domain for userPrincipalName, while keeping the user’s real email address in mail. This is also how many companies operate in practice.

Other issues

  • Test Connection gave no feedback in the UI
  • The SSO page was empty because the table was never populated with test data
  • Failed Entra syncs disappeared from the lifecycle view instead of showing up as errors

Reflection

This project taught me one thing above all else. As long as an integration is simulated, it’s easy to believe everything works. It’s only once it’s connected to a real system that the real problems surface. That’s also when the project started to feel like an actual IAM system instead of a demo.

Technical Note

AI was used to build most of the code implementation. My focus was on the direction and the IAM concepts behind the solution: separating HR from IGA, connecting an IGA system to a real Entra ID tenant, setting up the app registration, permissions, and secret in Entra ID, and testing the flow hard enough to find bugs like the Resync issue above.