<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Christian</title>
    <description>The latest articles on Forem by Christian (@chrisiam).</description>
    <link>https://forem.com/chrisiam</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3812678%2F048f8cd3-16d8-4f71-8154-d556ddfed6c3.png</url>
      <title>Forem: Christian</title>
      <link>https://forem.com/chrisiam</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/chrisiam"/>
    <language>en</language>
    <item>
      <title>Event-Driven Threat Detection: Building Real-Time Security on Conditional Access Gaps</title>
      <dc:creator>Christian</dc:creator>
      <pubDate>Thu, 26 Mar 2026 10:03:40 +0000</pubDate>
      <link>https://forem.com/chrisiam/event-driven-threat-detection-building-real-time-security-on-conditional-access-gaps-55j1</link>
      <guid>https://forem.com/chrisiam/event-driven-threat-detection-building-real-time-security-on-conditional-access-gaps-55j1</guid>
      <description>&lt;p&gt;In the previous post, we identified three key gaps that Conditional Access cannot address:&lt;/p&gt;

&lt;p&gt;Brute force patterns (e.g. 10 failures in 2 minutes)&lt;br&gt;
Activity from excluded users (e.g. executives bypassing geo-blocking)&lt;br&gt;
behavioural anomalies (e.g. Saturday midnight logins)&lt;/p&gt;

&lt;p&gt;This post builds the &lt;strong&gt;detection layer&lt;/strong&gt; that catches what CA misses. Not prevention but detection. Stream Analytics complements Conditional Access, not replaces it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this system detects&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Brute force patterns (5+ failures in 10-minute windows)&lt;/li&gt;
&lt;li&gt;Geographic anomalies from excluded users (non-UK access with no CA oversight)&lt;/li&gt;
&lt;li&gt;behavioural anomalies (off-hours activity from UK locations)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What this system does NOT detect&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token theft without anomalous sign-in activity&lt;/li&gt;
&lt;li&gt;Lateral movement after successful authentication&lt;/li&gt;
&lt;li&gt;Data exfiltration post-login&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This highlights a critical principle: identity security requires both preventative controls (Conditional Access) and detective controls (event-driven monitoring).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: This is about detection. This should feed into a SIEM integration for SOC investigation and response&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture: Event Hub + Stream Analytics
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F35e6ixg6wngwjqjiwpdw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F35e6ixg6wngwjqjiwpdw.png" alt="Architecture diagram" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Pipeline&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Entra ID sign-ins&lt;/strong&gt; → Real authentication events (success, CA blocks, password failures)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event Hub (signin-events)&lt;/strong&gt; → Buffers events for stream processing (2 partitions, 1-day retention)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream Analytics&lt;/strong&gt; → 3 continuous queries running SQL against event stream&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event Hub (threat-alerts)&lt;/strong&gt; → Stores detected threats with full investigation context&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why Event Hub?&lt;/strong&gt; Decouples collection from processing. Events persist even if Stream Analytics fails. Query has bug? Replay events with corrected query. Connection drops? Events buffered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why monitor excluded users?&lt;/strong&gt; When Senior Level executive logs in from New York, CA policy doesn't apply (user excluded from geo-blocking) → authentication succeeds → &lt;strong&gt;no CA oversight&lt;/strong&gt;. Stream Analytics flags this for investigation: legitimate executive travel or compromised account?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fusf2lzdyuvrmkl1j5j7w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fusf2lzdyuvrmkl1j5j7w.png" alt="CA policy configuration showing Senior Level group exclusions" width="800" height="1159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;: Terraform deploys everything in ~5 minutes. Event Hub Basic tier (£0.86/day), Stream Analytics 1 SU (£0.10/hour active).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F09iijofawneh2b7jqo89.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F09iijofawneh2b7jqo89.png" alt="Terraform state" width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fweqgypc9j0tqiq23vh1g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fweqgypc9j0tqiq23vh1g.png" alt="Azure portal" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;With the ingestion layer in place, we can now define detection logic as continuous queries running against the event stream.&lt;/p&gt;
&lt;h2&gt;
  
  
  Query 1: Brute Force Detection
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;: Detect 5+ authentication failures from same user within 10-minute window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;userPrincipalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;failed_attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;window_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Failed Login Spike'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;alert_type&lt;/span&gt;
&lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;FailedLoginOutput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EventHubInput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errorCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;userPrincipalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TumblingWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;minute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How tumbling windows work&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed 10-minute intervals&lt;/strong&gt;: 14:00-14:09, 14:10-14:19, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-overlapping&lt;/strong&gt;: User with 6 failures in one window → alert fires&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate windows&lt;/strong&gt;: 3 failures in window 1 + 3 in window 2 → no alert (distributed, not burst)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters&lt;/strong&gt;: Prevents alert fatigue from slow distributed attacks while catching concentrated bursts indicative of automated credential stuffing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key decision&lt;/strong&gt;: Include errorCode 53003 (CA blocks) or only 50126 (wrong passwords)?&lt;/p&gt;

&lt;p&gt;Early iteration excluded CA blocks (user might have correct password, wrong location—not a credential attack). After testing, included both to capture attackers trying multiple locations during brute force.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production teams&lt;/strong&gt;: Adjust based on threat model. Exclude 53003 for pure credential attacks. Include for comprehensive activity monitoring. Or better still; split them into separate detections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Query 2: Geographic Anomalies
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;: Flag ALL non-UK access for investigation. Operational staff will be CA-blocked, but we want to monitor excluded executives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;userPrincipalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ipAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Non-UK Access'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;alert_type&lt;/span&gt;
&lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;HighRiskOutput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EventHubInput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%United Kingdom%'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%UK%'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%, GB'&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Unknown, Unknown'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The GB Location Bug&lt;/strong&gt; (cost me an hour of debugging):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 1 (failed)&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%UK%'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%United Kingdom%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looked reasonable. Deployed. Tested.&lt;/p&gt;

&lt;p&gt;Alerts fired for Salford, Manchester, Islington—all UK cities!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem&lt;/strong&gt;: Entra ID uses ISO country code &lt;strong&gt;"GB"&lt;/strong&gt;, not "UK". Sign-in location appears as &lt;code&gt;"Salford, GB"&lt;/code&gt;, not &lt;code&gt;"Salford, UK"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My query checked &lt;code&gt;NOT LIKE '%UK%'&lt;/code&gt;. It correctly detected "Salford, GB" doesn't contain "UK"—so it flagged a UK city as non-UK. False positive cascade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Version 2 (deployed)&lt;/strong&gt;:&lt;br&gt;
Added &lt;code&gt;NOT LIKE '%, GB'&lt;/code&gt; to catch the ISO format. Also added &lt;code&gt;&amp;lt;&amp;gt; 'Unknown, Unknown'&lt;/code&gt; to filter geolocation lookup failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Never assume data format. Always inspect actual payloads before writing filters. The GB vs UK issue is obvious in hindsight—but you only find it by testing with real data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh57gedqgb0ans0p7qgb8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh57gedqgb0ans0p7qgb8.png" alt="Stream Analytics query editor showing GB location filter highlighted" width="796" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why alert on excluded users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Executive logs in from Singapore → Policy doesn't apply (excluded from geo-blocking) → Stream Analytics flags it → Security investigates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check group membership → Senior Level executive&lt;/li&gt;
&lt;li&gt;Verify CA policy exclusion → Correctly excluded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conclusion&lt;/strong&gt;: Legitimate travel (no action) OR unexpected location (escalate)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Alert forces human review of activity from users who bypass CA policies.&lt;/p&gt;
&lt;h2&gt;
  
  
  Query 3: Off-Hours Activity
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Purpose&lt;/strong&gt;: Flag UK-based logins on weekends or outside 9-5 UTC business hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;userPrincipalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DATEPART&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;login_hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DATEPART&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;day_of_week&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Off-Hours Activity'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;alert_type&lt;/span&gt;
&lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OffHoursOutput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;EventHubInput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%, GB'&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%United Kingdom%'&lt;/span&gt;
    &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%UK%'&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;DATEPART&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;weekday&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- Weekday numbering: 1=Sunday, 7=Saturday (verify DATEFIRST setting)&lt;/span&gt;
      &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;DATEPART&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;createdDateTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;UK-only location filter&lt;/strong&gt;: Non-UK logins already trigger Query 2 (geographic anomalies). This query focuses on unusual timing from allowed locations. Prevents duplicate alerts—keeps queries mutually exclusive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Location matching brittleness&lt;/strong&gt;: This query uses the same string pattern matching as Query 2 (&lt;code&gt;LIKE '%, GB'&lt;/code&gt;). For production, consider extracting &lt;code&gt;countryOrRegion&lt;/code&gt; during ingestion and using structured field comparison (&lt;code&gt;WHERE country = 'GB'&lt;/code&gt;) instead of string matching. More reliable and avoids the GB vs UK inconsistency issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezone considerations&lt;/strong&gt;: All Entra ID timestamps are UTC. This query checks &lt;strong&gt;UTC hours 9-17&lt;/strong&gt;, not local UK time. Implications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UK summer (BST, UTC+1): "9-5 UK time" = 8-16 UTC → query misses 16-17 UTC hour&lt;/li&gt;
&lt;li&gt;UK winter (GMT, UTC+0): "9-5 UK time" = 9-17 UTC → query is accurate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For precise UK business hours detection, adjust query to account for BST/GMT transitions or accept UTC-based approximation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Early iteration mistake&lt;/strong&gt;: Singapore login at 2 AM triggered BOTH Query 2 (non-UK) AND Query 3 (off-hours). Duplicate alerts cause investigation fatigue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: Added UK-only filter to Query 3. Now each query targets a distinct signal dimension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query 1&lt;/strong&gt;: Authentication failures (any location, any time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query 2&lt;/strong&gt;: Geographic anomalies (non-UK access, any time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query 3&lt;/strong&gt;: Behavioral anomalies (UK access, unusual timing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: A single event can still trigger multiple queries if it matches multiple dimensions. For example, a UK user failing login 10 times at 2 AM on Saturday triggers Query 1 (failures) AND Query 3 (off-hours). This is expected—each query surfaces a different investigation angle for the same suspicious event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Collection: Graph API to Event Hub
&lt;/h2&gt;

&lt;p&gt;Python script fetches sign-in logs from Graph API → transforms to simplified schema → sends to Event Hub in batches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get connection string from Terraform&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;terraform
terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; eventhub_send_connection_string

&lt;span class="c"&gt;# Add to .env file&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"EVENTHUB_CONNECTION_STRING=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; eventhub_send_connection_string&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ../scripts/.env

&lt;span class="c"&gt;# Process events&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../scripts
python export_signin_logs_to_eventhub.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Typical completion&lt;/strong&gt;: ~30 seconds for 3300 events.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcl8ei4rahycy3ww621s5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcl8ei4rahycy3ww621s5.png" alt="Python script output showing batch" width="800" height="1543"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema transformation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userPrincipalName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;userPrincipalName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errorCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;errorCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="si"&gt;{}&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;countryOrRegion&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ipAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ipAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;createdDateTime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;createdDateTime&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Debugging gotcha&lt;/strong&gt;: Script crashed with cryptic DNS error (&lt;code&gt;getaddrinfo failed&lt;/code&gt;). Looked like network issue. Spent 20 minutes checking firewalls, DNS settings, network connectivity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actually&lt;/strong&gt;: Extra quote in &lt;code&gt;.env&lt;/code&gt; file: &lt;code&gt;'Endpoint=sb://...&lt;/code&gt;. Parser read the quote, couldn't parse hostname, threw DNS error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson&lt;/strong&gt;: Connection string format errors manifest as network failures. Print connection strings (redacted) to verify exact format before debugging network stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Investigation Workflows
&lt;/h2&gt;

&lt;p&gt;All 3300 threats stored in Event Hub &lt;code&gt;threat-alerts&lt;/code&gt; with complete investigation context. Azure Portal → Event Hub → Data Explorer shows each threat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 1: Brute Force Attack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx3fxqtyrsxj7r98vjcjg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx3fxqtyrsxj7r98vjcjg.png" alt=" " width="800" height="328"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The sign-in logs show repeated failures (error code 50126) from a UK location.&lt;br&gt;
Individually, these events would not trigger Conditional Access. However, when analysed as a sequence, they form a clear brute force pattern and worth investigating.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ywl5t2fpt00e85hf87s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2ywl5t2fpt00e85hf87s.png" alt=" " width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check user's normal location: UK (matches profile?)&lt;/li&gt;
&lt;li&gt;Failed attempts: 10 in 2 minutes (definite brute force pattern)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action&lt;/strong&gt;: Contact user to verify, force password reset, review MFA enrollment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Example 2: Excluded User (Critical Scenario)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3p0nh883acynp2al3t0g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3p0nh883acynp2al3t0g.png" alt=" " width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Alert received&lt;/strong&gt;: Non-UK access from New York&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Entra ID&lt;/strong&gt;: Navigate to user → Group memberships&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery&lt;/strong&gt;: User is member of "Senior Level" group (executives)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify CA policy&lt;/strong&gt;: "LAB - Block Non UK (Exclude Senior Level)" → Senior Level group excluded&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conclusion&lt;/strong&gt;: &lt;strong&gt;Expected (excluded executive), NOT breach&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Policy doesn't apply to this user (excluded for travel requirements). No remediation needed—log for audit trail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbeed33qnk1up1j944u2i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbeed33qnk1up1j944u2i.png" alt=" " width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This demonstrates defense-in-depth&lt;/strong&gt;: CA policy doesn't apply (excluded), Stream Analytics flagged it for visibility, security team confirms expected behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without Stream Analytics, this login is invisible.&lt;/strong&gt; No flag, no investigation, no confirmation that exclusion is being used legitimately vs. account compromise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example 3: Off-Hours Activity&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2djv6i66dw40uhzqxr0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2djv6i66dw40uhzqxr0m.png" alt=" " width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Location: UK (expected for this user)&lt;/li&gt;
&lt;li&gt;Time: Saturday 11:34 (outside Mon-Fri 9-5)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action&lt;/strong&gt;: Log for trend analysis, contact user if pattern emerges&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Key Learnings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Real Data Reveals Edge Cases&lt;/strong&gt;: GB vs UK location bug (Entra uses &lt;code&gt;"Salford, GB"&lt;/code&gt;), CA blocks (53003) vs auth failures (50126) require different handling, connection string format errors manifest as DNS failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Event Hub Decouples Collection from Processing&lt;/strong&gt;: Events persist even if Stream Analytics fails. Query bug? Replay with corrected query. Critical for production reliability. This also enables replay—allowing you to reprocess historical events with updated detection logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Alert Design Prevents Fatigue&lt;/strong&gt;: Early iteration had duplicate alerts (Singapore 2 AM triggered geo + off-hours). Fix: UK-only filter for Query 3. Each query targets distinct signal dimension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. IaC Accelerates Iteration&lt;/strong&gt;: Manual deployment ~2 hours. Terraform &lt;code&gt;apply&lt;/code&gt; ~5 minutes. Debugging 2 query bugs took ~30 minutes with IaC vs. 4+ hours manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Audit: Stream Analytics in Production
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is a LAB environment.&lt;/strong&gt; The following security gaps would fail production review.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What We Skipped (Intentionally)&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Lab Setup&lt;/th&gt;
&lt;th&gt;Production Requirement&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Public Event Hub endpoints&lt;/td&gt;
&lt;td&gt;Private Link + VNet integration&lt;/td&gt;
&lt;td&gt;Data exposure, unauthorized access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Connection strings in .env files&lt;/td&gt;
&lt;td&gt;Azure Key Vault + managed identities&lt;/td&gt;
&lt;td&gt;Credential theft, no rotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Encryption&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic tier (no encryption at rest)&lt;/td&gt;
&lt;td&gt;Premium tier with customer-managed keys&lt;/td&gt;
&lt;td&gt;Data breach if storage compromised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Query Changes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual edits in portal&lt;/td&gt;
&lt;td&gt;CI/CD with approval gates&lt;/td&gt;
&lt;td&gt;Accidental query breakage, no audit trail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No alerting on job failures&lt;/td&gt;
&lt;td&gt;Azure Monitor alerts + runbooks&lt;/td&gt;
&lt;td&gt;Silent detection failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data Retention&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1-day Event Hub retention&lt;/td&gt;
&lt;td&gt;Long-term storage (SQL/Log Analytics)&lt;/td&gt;
&lt;td&gt;Compliance violations, lost audit trail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Production Must-Haves for Stream Analytics&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Network Isolation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Private Link for Event Hub (~£12/month per endpoint)&lt;/li&gt;
&lt;li&gt;VNet integration for Stream Analytics job&lt;/li&gt;
&lt;li&gt;Network security groups restricting inbound/outbound&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Identity &amp;amp; Access&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Managed identities for Stream Analytics → Event Hub authentication&lt;/li&gt;
&lt;li&gt;Azure Key Vault for any connection strings (Python script)&lt;/li&gt;
&lt;li&gt;RBAC with least privilege (no account keys in queries)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Data Protection&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Premium Event Hub with encryption at rest (~£531/month)&lt;/li&gt;
&lt;li&gt;Customer-managed keys (CMK) for compliance&lt;/li&gt;
&lt;li&gt;TLS 1.2+ for all data in transit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. Operational Excellence&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-region deployment for disaster recovery&lt;/li&gt;
&lt;li&gt;Automated failover for Event Hub namespace&lt;/li&gt;
&lt;li&gt;Azure Monitor alerts on: job stopped, output errors, SU utilization &amp;gt;80%&lt;/li&gt;
&lt;li&gt;Runbooks for common failure scenarios&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;5. Change Management&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Version control for query definitions (Git)&lt;/li&gt;
&lt;li&gt;CI/CD pipeline for query deployments&lt;/li&gt;
&lt;li&gt;Approval gates before production changes&lt;/li&gt;
&lt;li&gt;Rollback capability if query breaks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;6. Compliance &amp;amp; Audit&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forward threat-alerts to Log Analytics workspace (7-year retention)&lt;/li&gt;
&lt;li&gt;Immutable audit logs for compliance&lt;/li&gt;
&lt;li&gt;Data residency controls for GDPR/regional requirements&lt;/li&gt;
&lt;li&gt;Regular access reviews for Event Hub/Stream Analytics permissions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Conditional Access evaluates individual sign-in events.&lt;br&gt;
This system detects patterns across events—the difference between blocking risk and understanding it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skills demonstrated&lt;/strong&gt;: Stream Analytics query development, Event Hub event-driven architecture, SQL tumbling windows, real-time threat detection, production security architecture design, infrastructure as code iteration.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>security</category>
      <category>streamanalytics</category>
      <category>identity</category>
    </item>
    <item>
      <title>Conditional Access Realism: Testing Real Sign-Ins to Understand Policy Gaps</title>
      <dc:creator>Christian</dc:creator>
      <pubDate>Tue, 24 Mar 2026 15:07:08 +0000</pubDate>
      <link>https://forem.com/chrisiam/conditional-access-realism-testing-real-sign-ins-to-understand-policy-gaps-2c4i</link>
      <guid>https://forem.com/chrisiam/conditional-access-realism-testing-real-sign-ins-to-understand-policy-gaps-2c4i</guid>
      <description>&lt;p&gt;Your Conditional Access policy blocked risky logins last week. Working as designed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But can you answer these questions?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are your executives' travel logins being monitored?&lt;/li&gt;
&lt;li&gt;Can you detect 10 failed password attempts in 2 minutes?&lt;/li&gt;
&lt;li&gt;What happens when excluded users authenticate from unusual locations?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conditional Access makes point-in-time decisions: Allow or Block. Binary. Conditional Access policies themselves do not aggregate events over time or detect patterns. While Entra ID provides risk detections (e.g., via Identity Protection), these are separate systems and not configurable at the CA policy level. It can't tell you if a user failed authentication 10 times in 2 minutes. It can't flag unusual behavior from users excluded from your policies.&lt;/p&gt;

&lt;p&gt;We built a synthetic login-generator to simulate real-world authentication patterns. The generator intentionally models probabilistic attack behavior and temporal variance to mimic real-world authentication noise rather than uniform test traffic.&lt;/p&gt;

&lt;p&gt;UK locations and international travel. Successful logins and deliberate brute force attacks. Business hours activity and out of hours authentications. Policy compliance and executive exclusions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result&lt;/strong&gt;: CA enforced policy exactly as designed. But successful logins happened—some legitimate, some from excluded executives, some exhibiting patterns only visible through cross-event analysis.&lt;/p&gt;

&lt;p&gt;We're testing how Conditional Access protects identities—and discovering where it needs help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Conditional Access's Blind Spots
&lt;/h2&gt;

&lt;p&gt;Conditional Access excels at &lt;strong&gt;point-in-time decisions&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is this IP address risky? &lt;strong&gt;Block.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Is the sign-in location outside the UK? &lt;strong&gt;Block access.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every authentication request gets evaluated independently. Allow or deny. Binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CA cannot detect&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Patterns over time&lt;/strong&gt;: Five failed password attempts in 10 minutes? CA evaluates each attempt separately. No aggregation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Behavioral anomalies&lt;/strong&gt;: Finance manager who normally works 9-5 suddenly authenticating at 2 AM? CA doesn't track baselines.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Activity from excluded users&lt;/strong&gt;: Executive excluded from geo-blocking logs in from Singapore at midnight? Policy doesn't apply—authentication succeeds with zero oversight. No Conditional Access enforcement or policy-driven detection&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Distinction between policy blocks and authentication failures&lt;/strong&gt;: User blocked by CA (wrong location, correct password) looks different from brute force attempt (wrong password, allowed location).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These aren't CA failures; they're &lt;strong&gt;architectural limitations&lt;/strong&gt;. CA prevents threats at the door. It doesn't monitor patterns that develop across multiple authentication attempts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To test these limitations, we built realistic authentication data.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Authentication Event Generator
&lt;/h2&gt;

&lt;p&gt;We built a Python script using MSAL (Microsoft Authentication Library) to generate realistic authentication events across hundreds of users. The script randomly selects users from a pool and generates logins with user credentials, it also tries the wrong password 10 times in 2 mins for 5 percent of users and I use a VPN app to simulate different geo-logins. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Event Generation Strategy
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Event Distribution&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mix of successful and failed authentications&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geographic diversity&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;UK locations: Salford, Manchester, Islington&lt;/li&gt;
&lt;li&gt;Non-UK locations: New York, Zagreb, Qafsah, Karachi, Kyiv, etc&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Temporal patterns&lt;/strong&gt;: Business hours plus off-hours testing (late night, weekends)&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Attack simulation&lt;/strong&gt;: Concentrated bursts of rapid password failures (10 attempts per user in 2 minutes)&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this distribution?&lt;/strong&gt;: Real authentication traffic isn't uniform. Most activity happens during business hours from expected locations. Suspicious activity is the minority—but buried in normal traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Technical Implementation: MSAL + ROPC Flow
&lt;/h3&gt;

&lt;p&gt;The script uses Microsoft Authentication Library (MSAL) with Resource Owner Password Credential (ROPC) flow to generate real Entra ID sign-in events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;msal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PublicClientApplication&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redacted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;WRONG_PASSWORD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redacted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BRUTE_FORCE_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;  &lt;span class="c1"&gt;# 5% of logins trigger brute force
&lt;/span&gt;&lt;span class="n"&gt;BRUTE_FORCE_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;  &lt;span class="c1"&gt;# 10 attempts in 2 minutes
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_intentional_failure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate a real sign-in via ROPC flow&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PublicClientApplication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;authority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://login.microsoftonline.com/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TENANT_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire_token_by_username_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User.Read&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;✅ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - Login successful&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;❌ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - Failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

&lt;span class="c1"&gt;# Main loop: randomly select users and generate logins
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;user_pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;trigger_brute_force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;BRUTE_FORCE_RATE&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;trigger_brute_force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Brute force: 10 failed attempts in 2 minutes
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BRUTE_FORCE_ATTEMPTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;generate_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WRONG_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_intentional_failure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ~12 seconds between attempts
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Normal login
&lt;/span&gt;        &lt;span class="nf"&gt;generate_login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Rate limiting between users
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why ROPC for labs&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Creates real Entra ID sign-in events (triggers CA policies)&lt;/li&gt;
&lt;li&gt;✅ Generates authentic telemetry (same schema as production)&lt;/li&gt;
&lt;li&gt;✅ Scriptable and repeatable&lt;/li&gt;
&lt;li&gt;❌ ROPC bypasses MFA and modern authentication controls, making it unsuitable for production and risky even in poorly isolated environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Geographic diversity&lt;/strong&gt;: We manually switched VPN endpoints (UK: Salford, Manchester; Non-UK: Miami,Zagreb, Qafsah, Karachi, Kyiv, etc) before running the script. Each VPN location creates different IP addresses, ensuring Entra ID's geolocation service sees real non-UK sign-in attempts that trigger CA policy evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Conditional Access Policy Design
&lt;/h2&gt;

&lt;p&gt;We created a policy that reflects realistic business requirements: strict geo-blocking for operational staff, global access for executives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy Name&lt;/strong&gt;: "LAB - Block Non UK (Exclude Senior Level)"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6hllqliz6rvrrtjsjtt6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6hllqliz6rvrrtjsjtt6.png" alt="Conditional Access Policy Configuration" width="800" height="668"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;*Policy blocks non-UK locations but excludes Senior group—creating a monitoring gap we'll address in next section&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Policy Configuration&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Rationale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Users&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All users&lt;/td&gt;
&lt;td&gt;Policy applies to entire directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Exclusions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Senior Level group (executives)&lt;/td&gt;
&lt;td&gt;Global access requirements (travel, M&amp;amp;A, incident response)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloud apps&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All cloud apps&lt;/td&gt;
&lt;td&gt;Protect all resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Locations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;NOT United Kingdom&lt;/td&gt;
&lt;td&gt;Enforce only for non-UK sign-ins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Grant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Block access&lt;/td&gt;
&lt;td&gt;Zero tolerance for non-UK access from operational staff&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The Three User Populations This Creates&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Operational Staff from UK&lt;/strong&gt; → Policy applies → Location = UK → &lt;strong&gt;Access granted&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational Staff from Non-UK&lt;/strong&gt; → Policy applies → Location ≠ UK → &lt;strong&gt;Access blocked (errorCode: 53003)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Senior Level Executives from Non-UK&lt;/strong&gt; → Policy doesn't apply (excluded) → &lt;strong&gt;Access granted&lt;/strong&gt; (monitoring gap)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why Senior Levels needs exclusions&lt;/strong&gt;: CEO travels for board meetings, CFO accesses systems during international audits, security team responds to incidents 24/7 from any location. Strict geo-blocking would block critical business operations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Critical security consideration&lt;/strong&gt;: While the policy doesn't apply to excluded users (no CA enforcement), &lt;strong&gt;Entra ID still generates sign-in events for every authentication attempt&lt;/strong&gt;. These events contain full context (user, location, IP, timestamp, outcome) and flow to our monitoring systems. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Excluded users' activity is logged and available for investigation&lt;/li&gt;
&lt;li&gt;✅ SOC can detect suspicious patterns from executive accounts (unusual locations, off-hours access)&lt;/li&gt;
&lt;li&gt;✅ Forensic analysis possible if executive account is compromised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The gap&lt;/strong&gt;: Detection isn't automated within CA policy framework. Manual log review required—or Stream Analytics (next section) for real-time monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Understanding CA Evaluation Outcomes
&lt;/h2&gt;

&lt;p&gt;When Conditional Access evaluates an authentication request, there are &lt;strong&gt;four possible outcomes&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Outcome 1: SUCCESS (Policy Satisfied)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userPrincipalName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chloe.oconnel@acme.onmicrosoft.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conditionalAccessStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Manchester"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"countryOrRegion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GB"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this means&lt;/strong&gt;: User authenticated successfully, CA evaluated and all conditions met (UK location), access granted. Normal activity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvsthb0w2czccwmyb802z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvsthb0w2czccwmyb802z.png" alt="UK user login" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Outcome 2: FAILURE - CA Block (Policy Enforced)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userPrincipalName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ethan.Bell@acme.onmicrosoft.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;53003&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"failureReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Blocked by Conditional Access"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conditionalAccessStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"failure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Zagreb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"countryOrRegion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Croatia"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this means&lt;/strong&gt;: User might have correct password, but CA blocked before authentication completed. Policy condition not met (non-UK location). No access token issued.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CRITICAL DISTINCTION&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;errorCode 53003&lt;/code&gt; = CA block (policy prevented access)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;errorCode 50126&lt;/code&gt; = Invalid password (authentication failure, potential brute force)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;These are different security events&lt;/strong&gt;: CA block means user blocked by policy (could have correct password, wrong location). Auth failure means wrong password (typo or attack).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgj4tbk91uhorh13z0f4i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgj4tbk91uhorh13z0f4i.png" alt="Screenshot from Entra ID sign-in logs showing a Conditional Access block with conditionalAccessStatus: " width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Outcome 3: NOT APPLIED (Policy Exemption - Monitoring Gap)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userPrincipalName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wei.huang@acme.onmicrosoft.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conditionalAccessStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notApplied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Qafsah"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"countryOrRegion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tunisia"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"appliedConditionalAccessPolicies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"LAB - Block Non UK (Exclude Senior Level)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notApplied"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this means&lt;/strong&gt;: User is in excluded group (Senior Level executives). CA policy doesn't evaluate this user at all. Authentication succeeds based on credentials alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the security gap&lt;/strong&gt;: Excluded users' activity happens with no CA oversight. If executive account is compromised, attacker gets same exemptions. No automated detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation required&lt;/strong&gt;: Manual review needed. Is this legitimate travel or compromised executive account?&lt;/p&gt;

&lt;p&gt;Stream Analytics will monitor &lt;code&gt;conditionalAccessStatus: "notApplied"&lt;/code&gt; events to flag excluded users' activity.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpp3tybjm8npwdfwekkcp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpp3tybjm8npwdfwekkcp.png" alt="User Sign in logs" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8c5hv2pz1jw8bi3tnt2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe8c5hv2pz1jw8bi3tnt2.png" alt="Password failure" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3nfeli0j0s1ouzz0z7sx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3nfeli0j0s1ouzz0z7sx.png" alt="User in Group" width="800" height="219"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Outcome 4: FAILURE - Authentication Failure (Wrong Password)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userPrincipalName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rhys.oconnel@acme.onmicrosoft.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"errorCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50126&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"failureReason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Invalid username or password"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"conditionalAccessStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"notApplied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Salford"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"countryOrRegion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GB"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this means&lt;/strong&gt;: User failed to authenticate&lt;/p&gt;

&lt;p&gt;(wrong password). CA was not evaluated because authentication failed before CA stage.This ordering is critical: it explains why brute force attacks (invalid credentials) never reach Conditional Access and therefore bypass policy evaluation entirely but should be worth investigating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why CA shows &lt;code&gt;notApplied&lt;/code&gt;&lt;/strong&gt;: Authentication pipeline validates credentials first, then evaluates CA. If credentials fail, CA never runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the signal for brute force detection&lt;/strong&gt;: Multiple errorCode 50126 events in short time window = potential attack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k0upjls5r43iazjsc5m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8k0upjls5r43iazjsc5m.png" alt="Password Incorrect" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhg7rs7csmycxtssk5tcn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhg7rs7csmycxtssk5tcn.png" alt="Error code 50126" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;User is logging in from New York so should be blocked by CA but it was not applied as it failed password authentication before reaching the CA engine so it was not applied in this case. &lt;/p&gt;




&lt;h2&gt;
  
  
  Comparison Table: The Four Outcomes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;errorCode&lt;/th&gt;
&lt;th&gt;conditionalAccessStatus&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Investigation?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Success&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;success&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User authenticated, policy met&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CA Block&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;53003&lt;/td&gt;
&lt;td&gt;&lt;code&gt;failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policy blocked access&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Not Applied (Excluded)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notApplied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policy skipped (user excluded)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;YES&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth Failure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50126&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notApplied&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wrong password&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;YES&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Key takeaway&lt;/strong&gt;: Not all "notApplied" events are the same. Excluded users vs. authentication failures both show &lt;code&gt;notApplied&lt;/code&gt;, but for different reasons. Check errorCode to distinguish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Scenarios: Policy in Action
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Scenario 1: Operational Staff from Non-UK (CA Block)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: IT admin attempting login from Zagreb&lt;br&gt;
&lt;strong&gt;CA Evaluation&lt;/strong&gt;: Policy applies → Location = Zagreb, Croatia (non-UK) → &lt;strong&gt;Blocked (errorCode 53003)&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Result&lt;/strong&gt;: Access denied. Policy working as designed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2: Senior Level Executive from Non-UK (Monitoring Gap)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: An executive login from Qafsah at 10:24 PM&lt;br&gt;
&lt;strong&gt;CA Evaluation&lt;/strong&gt;: User in Senior Level group → Policy doesn't apply → &lt;strong&gt;Access granted (notApplied)&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Result&lt;/strong&gt;: Login succeeds. No CA oversight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation needed&lt;/strong&gt;: Is this legitimate travel or compromised executive account? CA can't tell—it never evaluated the risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next section&lt;/strong&gt;: Stream Analytics will flag all non-UK access from excluded users for investigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 3: Brute Force Attack (CA Can't Detect)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;:  A user with 10 wrong password attempts in 2 minutes from UK&lt;br&gt;
&lt;strong&gt;CA Evaluation&lt;/strong&gt;: Each attempt fails authentication → CA never runs (evaluated independently)&lt;br&gt;
&lt;strong&gt;Result&lt;/strong&gt;: 10 failed logins in 2 minutes. CA policies do not natively detect or respond to rapid authentication failures. While platform-level protections like smart lockout may mitigate this, they are not visible or tunable within Conditional Access policy logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CA detects&lt;/strong&gt;: Nothing. CA evaluates each attempt independently—no aggregation, no pattern detection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next section&lt;/strong&gt;: Stream Analytics will aggregate errorCode 50126 events over time windows to detect brute force.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 4: Off-Hours Activity (CA Allows, But Suspicious)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;User&lt;/strong&gt;: HR coordinator at 2:13 AM Saturday from Salford&lt;br&gt;
&lt;strong&gt;CA Evaluation&lt;/strong&gt;: Location = UK → Policy met → &lt;strong&gt;Access granted&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Result&lt;/strong&gt;: Login succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is suspicious&lt;/strong&gt;: HR coordinator normally works Mon-Fri 9-5. Why 2 AM Saturday? CA doesn't track baselines or detect behavioral anomalies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next section&lt;/strong&gt; Stream Analytics will flag off-hours activity for investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: What CA Caught and Missed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Conditional Access Successfully Protected
&lt;/h3&gt;

&lt;p&gt;✅ &lt;strong&gt;Geo-blocking enforcement&lt;/strong&gt;: Non-UK login attempts from operational staff blocked&lt;br&gt;
✅ &lt;strong&gt;Policy compliance&lt;/strong&gt;: Every non-UK operational staff login denied&lt;br&gt;
✅ &lt;strong&gt;Legitimate access&lt;/strong&gt;: Successful logins from authorized users in compliant scenarios&lt;br&gt;
✅ &lt;strong&gt;Executive mobility&lt;/strong&gt;: Senior Level global access maintained&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CA did exactly what it's designed to do&lt;/strong&gt;: Evaluate each authentication request against location policy, enforce blocks, allow compliant requests and exempted users.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Conditional Access Missed
&lt;/h3&gt;

&lt;p&gt;❌ &lt;strong&gt;Brute force patterns&lt;/strong&gt;: Rapid authentication failures went undetected—CA evaluated each attempt independently&lt;br&gt;
❌ &lt;strong&gt;Excluded user activity&lt;/strong&gt;: Non-UK logins from Senior Level executives happened with no CA oversight&lt;br&gt;
❌ &lt;strong&gt;Behavioral anomalies&lt;/strong&gt;: Off-hours logins from operational staff allowed (UK location satisfied policy, but timing unusual)&lt;br&gt;
❌ &lt;strong&gt;Attack progression&lt;/strong&gt;: No visibility into whether failed attempts escalate over time&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;These aren't CA failures—they're gaps in CA's detection capabilities.&lt;/strong&gt; CA prevents known threats. It doesn't detect patterns over time, monitor exempted users, or identify behavioral anomalies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Audit: Lab vs. Production Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;This is a LAB environment for learning.&lt;/strong&gt; The following security gaps exist and would fail production review:&lt;/p&gt;

&lt;h3&gt;
  
  
  What We Skipped (Intentionally)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Secrets Management&lt;/strong&gt;: Client ID and Tenant ID hardcoded in Python script. Production requires Azure Key Vault with managed identities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ROPC Flow&lt;/strong&gt;: Deprecated authentication method that bypasses MFA. Production must use interactive flows (authorization code flow, device code flow) that support modern security features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Password Storage&lt;/strong&gt;: Plaintext passwords in script constants (redacted in snip}. Production requires secure credential management and certificate-based authentication.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate Limiting&lt;/strong&gt;: No backoff logic for API throttling. Production needs exponential backoff and retry policies.&lt;/p&gt;

&lt;h3&gt;
  
  
  Production Requirements
&lt;/h3&gt;

&lt;p&gt;When deploying authentication testing in production environments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use service principals with certificate authentication&lt;/strong&gt; (not ROPC with passwords)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store credentials in Azure Key Vault&lt;/strong&gt; (retrieve via managed identity)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement proper error handling&lt;/strong&gt; (API throttling, network failures, authentication errors)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use test users in isolated tenant&lt;/strong&gt; (never test against production user accounts)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal Graph API scopes&lt;/strong&gt; (only AuditLog.Read.All for reading sign-in logs, not Directory.Read.All)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why we use ROPC in this lab&lt;/strong&gt;: Creates authentic Entra ID sign-in events that trigger real CA policy evaluation. Allows reproducible testing scenarios. Production would use interactive authentication or real user logins for testing, not scripted automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Gaps CA Can't Fill
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gap 1: Pattern Detection Over Time (Brute Force)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What CA can't do&lt;/strong&gt;: Aggregate authentication failures over time windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-world impact&lt;/strong&gt;: Attacker tries hundreds of passwords over hours from UK-based VPS. Each attempt: UK location (policy allows), wrong password (auth fails, CA doesn't evaluate). No detection, no lockout, no alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution needed&lt;/strong&gt;: Time-windowed aggregation. "If user fails authentication 5+ times in 10 minutes, trigger alert."&lt;/p&gt;

&lt;h3&gt;
  
  
  Gap 2: Monitoring Excluded Users (Executive Compromise)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What CA can't do&lt;/strong&gt;: Monitor excluded users' activity. No Conditional Access enforcement or policy-driven detection is applied to excluded users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-world impact&lt;/strong&gt;: Executive account compromised. Attacker logs in from Singapore at 2 AM. Policy doesn't apply (executive excluded) → Access granted → No alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution needed&lt;/strong&gt;: Monitoring layer that flags ALL non-UK access (including excluded users) for investigation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gap 3: Behavioral Anomaly Detection (Off-Hours)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What CA can't do&lt;/strong&gt;: Establish per-user behavioral baselines and detect deviations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-world impact&lt;/strong&gt;: Finance manager account compromised. Attacker logs in from UK-based VPS at midnight. UK location = policy allows. Unusual timing goes unnoticed for days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solution needed&lt;/strong&gt;: Behavioral analytics that track normal patterns per user and flag outliers.&lt;/p&gt;

&lt;p&gt;Next section will show you how to build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: CA Prevents, Detection Detects
&lt;/h2&gt;

&lt;p&gt;We tested Conditional Access with realistic authentication data:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What CA does brilliantly&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Blocked non-UK login attempts from operational staff&lt;/li&gt;
&lt;li&gt;✅ Maintained executive mobility&lt;/li&gt;
&lt;li&gt;✅ Binary decisions executed instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What CA cannot do&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Detect patterns over time (brute force)&lt;/li&gt;
&lt;li&gt;❌ Monitor excluded users' activity (blind spot)&lt;/li&gt;
&lt;li&gt;❌ Identify behavioral anomalies (off-hours, unusual locations)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conditional Access evaluates context (location, device, risk signals) but not intent. A sequence of low-risk events can still represent high-risk behavior when viewed over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This isn't a criticism of CA—it's understanding its design&lt;/strong&gt;: Conditional Access is a &lt;strong&gt;prevention control&lt;/strong&gt;. It's not designed for continuous monitoring, pattern detection, or behavioral analytics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defense-in-depth requires both&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conditional Access&lt;/strong&gt;: Prevents threats at the door&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream Analytics&lt;/strong&gt; (next section): Detects patterns that slip through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Skills demonstrated&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MSAL authentication with ROPC flow (generating real Entra ID sign-in events for testing)&lt;/li&gt;
&lt;li&gt;Realistic authentication data generation (geographic and temporal diversity)&lt;/li&gt;
&lt;li&gt;Conditional Access policy design (geo-blocking with executive exclusions)&lt;/li&gt;
&lt;li&gt;CA evaluation outcome interpretation (Success, Failure, Not Applied, Auth Failure)&lt;/li&gt;
&lt;li&gt;Security gap analysis (what CA detects vs. what it misses)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Next: Event-Driven Stream Analytics Threat Detection — where we'll aggregate these events in real-time to catch brute force, monitor excluded users, and flag off-hours anomalies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We have the authentication data. We understand CA's gaps. Now let's build real-time detection that catches the patterns CA can't see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conditional Access evaluates events. Detection systems evaluate sequences.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security failures happen in the gap between those two questions.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>azure</category>
      <category>security</category>
      <category>conditionalaccess</category>
      <category>identity</category>
    </item>
    <item>
      <title>Building Hybrid Identity at Scale: 1,000+ Users from Active Directory to Entra ID</title>
      <dc:creator>Christian</dc:creator>
      <pubDate>Sun, 15 Mar 2026 20:35:05 +0000</pubDate>
      <link>https://forem.com/chrisiam/building-hybrid-identity-at-scale-1000-users-from-active-directory-to-entra-id-4e11</link>
      <guid>https://forem.com/chrisiam/building-hybrid-identity-at-scale-1000-users-from-active-directory-to-entra-id-4e11</guid>
      <description>&lt;p&gt;Description: Built a production-scale hybrid identity lab with 1,158 users &lt;br&gt;
synced from Active Directory to Microsoft Entra ID. Used Terraform &lt;br&gt;
for infrastructure-as-code, persistent disks to save £65/month, &lt;br&gt;
and automated PowerShell imports. Includes UPN transformation, &lt;br&gt;
OU structure, and honest security trade-offs. &lt;br&gt;
Total cost: &amp;lt;£5/month&lt;br&gt;
series: AI-IAM-Engineering-Lab&lt;/p&gt;

&lt;p&gt;Most hybrid identity tutorials show you how to sync 5 test users. That's like learning to drive in an empty parking lot, then wondering why you crash in rush hour traffic. The real problems—UPN transformations that don't make sense, sync conflicts at scale, performance issues with thousands of objects—only surface when you're actually operating at volume.&lt;/p&gt;

&lt;p&gt;This post walks through building a hybrid identity environment with 1,158 users. Over a thousand users with realistic department distributions, job titles, and organizational structures. We're deploying Active Directory on Azure using Terraform, automating the entire user import with PowerShell, and synchronizing everything to Microsoft Entra ID while dealing with the UPN transformation headaches.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Hybrid Identity Still Matters in 2026
&lt;/h2&gt;

&lt;p&gt;Despite what cloud vendors want you to believe, 95% of enterprises still run hybrid identity. On-premises Active Directory isn't disappearing anytime soon. Legacy applications authenticate against it. Group policies manage thousands of workstations. And every single M365 deployment needs identity synchronization unless you're starting from absolute zero.&lt;/p&gt;

&lt;p&gt;The gap between "cloud-only" marketing and operational reality creates a massive skill shortage. Identity engineers who can architect hybrid solutions at scale remain valuable precisely because this knowledge is just absolutely necessary to keep enterprises running.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture: The Full Picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft5l6tgf553zm339j3w5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft5l6tgf553zm339j3w5b.png" alt="Complete hybrid identity architecture" width="428" height="836"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Complete hybrid identity architecture: AD syncing to Entra ID via Entra Connect with UPN transformation&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-Premises Layer&lt;/strong&gt; (simulated in Azure):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Windows Server 2022 domain controller running &lt;code&gt;aiiam.local&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;1,158 users across six departments&lt;/li&gt;
&lt;li&gt;Department-based OU structure&lt;/li&gt;
&lt;li&gt;Users authenticate with &lt;code&gt;@aiiam.local&lt;/code&gt; UPN&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Synchronization Layer&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Entra Connect with Password Hash Synchronization&lt;/li&gt;
&lt;li&gt;30-minute delta sync intervals&lt;/li&gt;
&lt;li&gt;UPN suffix transformation from &lt;code&gt;.local&lt;/code&gt; to &lt;code&gt;.onmicrosoft.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OU filtering for organizational users only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cloud Layer&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Entra ID tenant: &lt;code&gt;acme.onmicrosoft.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;1,158 synchronized users with hybrid identity attributes&lt;/li&gt;
&lt;li&gt;Both UPNs work for seamless SSO&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Active Directory uses &lt;code&gt;sarah.mitchell@aiiam.local&lt;/code&gt;, but Entra ID needs a routable domain. Entra Connect handles this transparently—users can authenticate with either UPN.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwf6tqtxfvaos796jwfwd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwf6tqtxfvaos796jwfwd.png" alt="Image showing AD services, .local forest and IP address " width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Domain controller IDENTITYDC running aiiam.local with all AD services operational&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Infrastructure Deployment: Terraform for Repeatability
&lt;/h2&gt;

&lt;p&gt;Manual setup works for one-off demos. For labs you'll teardown and rebuild? Infrastructure-as-Code saves hours.&lt;/p&gt;

&lt;p&gt;The Terraform config handles everything: virtual network, domain controller VM, storage for user data, and—critically—a persistent OS disk that survives &lt;code&gt;terraform destroy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the persistent disk configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_windows_virtual_machine"&lt;/span&gt; &lt;span class="s2"&gt;"ad_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ad-dc-vm"&lt;/span&gt;
  &lt;span class="nx"&gt;computer_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"IDENTITYDC"&lt;/span&gt;
  &lt;span class="nx"&gt;size&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard_D2s_v3"&lt;/span&gt;

  &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SystemAssigned"&lt;/span&gt;  &lt;span class="c1"&gt;# Managed identity for secure blob access&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;os_disk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ad-dc-os-disk-persistent"&lt;/span&gt;
    &lt;span class="nx"&gt;caching&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ReadWrite"&lt;/span&gt;
    &lt;span class="nx"&gt;storage_account_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard_LRS"&lt;/span&gt;
    &lt;span class="nx"&gt;disk_size_gb&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disk name &lt;code&gt;ad-dc-os-disk-persistent&lt;/code&gt; makes Azure treat it as pre-existing on subsequent deployments. First run: fresh Windows Server image. After &lt;code&gt;terraform destroy&lt;/code&gt; and reapply: it reattaches the existing disk with your DC config intact.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Game Changer&lt;/strong&gt;: This persistent disk strategy transformed my workflow. Initial setup: 2-3 hours (domain promotion, user imports, Entra Connect config). With persistent disks: &lt;code&gt;terraform destroy&lt;/code&gt; when done (saves money), &lt;code&gt;terraform apply&lt;/code&gt; when needed (back online in 10 minutes with everything intact). For labs that run intermittently, this is the difference between £90/month and £5/month.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cost optimization for intermittent labs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_dev_test_global_vm_shutdown_schedule"&lt;/span&gt; &lt;span class="s2"&gt;"ad_vm_shutdown"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;virtual_machine_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_windows_virtual_machine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ad_server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;daily_recurrence_time&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1900"&lt;/span&gt;
  &lt;span class="nx"&gt;timezone&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GMT Standard Time"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auto-shutdown at 7 PM GMT means you're only paying for compute when actively using the lab. VM stopped: £0.02/day for disk storage. Total monthly cost: under £5. This is also a fail safe incase I forget to terraform destroy after a demo day to avoid charges for VM running. &lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;terraform apply&lt;/code&gt; and watch Azure populate your resource group:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnqc5rqmle70zjie1cche.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnqc5rqmle70zjie1cche.png" alt="Terraform-deployed resources" width="800" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Terraform-deployed resources: VM, persistent disk, networking, and storage&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything you need for hybrid identity lands in one resource group: persistent OS disk, Standard_D2s_v3 VM, storage account with the user JSON, network interface with static IP, security group, public IP, and virtual network. Tags track project phase for cost analysis across multiple lab environments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fln9s0a28686mmowq59fr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fln9s0a28686mmowq59fr.png" alt="User data JSON file uploaded to blob storage for VM initialization" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;User data JSON file uploaded to blob storage for VM initialization&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Terraform uploads &lt;code&gt;entra_users.json&lt;/code&gt; to blob storage, VM downloads it using a time-limited SAS token. Keeps sensitive data out of custom script extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Active Directory Setup: Getting the Domain Right
&lt;/h2&gt;

&lt;p&gt;Domain name choice matters. I used &lt;code&gt;aiiam.local&lt;/code&gt; because &lt;code&gt;.local&lt;/code&gt; is conventional for on-premises AD. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8zhef9ei1hy52yknry4z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8zhef9ei1hy52yknry4z.png" alt="Installing AD DS features" width="800" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Installing AD DS features and promoting to domain controller with aiiam.local domain&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After promotion and the inevitable reboot:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5doyp5cty5t61yc2mld6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5doyp5cty5t61yc2mld6.png" alt="Service all running" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AD Web Services, DNS, Netlogon, and NTDS running—domain controller is operational&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Organizational Unit Structure: Preparing for Scale
&lt;/h2&gt;

&lt;p&gt;OU structure isn't just tidiness—it impacts Entra Connect filtering, Group Policy, and administrative delegation. I created &lt;code&gt;AIIAM-Users&lt;/code&gt; as the parent OU holding all departmental OUs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DC=aiiam,DC=local
├── AIIAM-Users
│   ├── Engineering
│   ├── Operations
│   ├── Executive
│   ├── Finance
│   ├── People
│   └── Security
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fberkrqltatag1tunq88r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fberkrqltatag1tunq88r.png" alt="Department-based organizational units" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Department-based organizational units under AIIAM-Users parent container&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkipmt71fvu5dsrxjkzf2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkipmt71fvu5dsrxjkzf2.png" alt="Users OU structure" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AIIAM-Users OU structure populated with users distributed across departments&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The parent OU becomes the sync boundary. Default containers stay on-premises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Importing 1,158 Users: Automation at Work
&lt;/h2&gt;

&lt;p&gt;The user data: 1,158 AI-generated identities in &lt;code&gt;entra_users.json&lt;/code&gt;. Each has realistic attributes—display name, job title, department, office location. The JSON came from Entra ID's Graph API format, which means it needed transformation for AD import (because of course Microsoft's two identity systems use different attribute names for the same data).&lt;/p&gt;

&lt;p&gt;The PowerShell script does a few things: reads the JSON, creates OUs if they're missing, sets the correct UPN suffix, and most importantly—shows progress every 100 users so you don't spend 8 minutes wondering if it crashed.&lt;/p&gt;

&lt;p&gt;Here's the core import logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Read and parse JSON&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\IdentityLab\entra_users.json"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ConvertFrom-Json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nx"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Loaded &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Count&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; users from JSON"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ForegroundColor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Cyan&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Department mapping from job titles&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$departmentMap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Engineer"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Operations"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Operations"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Executive"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Executive"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Finance"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Finance"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"HR"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"People"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Director"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Security"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;userPrincipalName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-split&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="c"&gt;# Determine department from job title&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Operations"&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# default&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$departmentMap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jobTitle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$departmentMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="kr"&gt;break&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="n"&gt;New-ADUser&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;displayName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-GivenName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;givenName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Surname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;surname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-UserPrincipalName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="s2"&gt;@aiiam.local"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-SamAccountName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$username&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;jobTitle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Department&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Office&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;officeLocation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OU=&lt;/span&gt;&lt;span class="nv"&gt;$department&lt;/span&gt;&lt;span class="s2"&gt;,OU=AIIAM-Users,DC=aiiam,DC=local"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-AccountPassword&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ConvertTo-SecureString&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"redacted!"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-AsPlainText&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="nt"&gt;-Enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-eq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Imported &lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt; users..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ForegroundColor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Green&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ptdl10hyijy8vltor6z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ptdl10hyijy8vltor6z.png" alt="Import script processing 1,100+ users with progress indicators" width="800" height="541"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Import script processing 1,100+ users with progress indicators&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Import completed in 8 minutes. Progress indicators every 100 users prevented that "is this frozen?" anxiety.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq10esxmgzyegujci6l7p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq10esxmgzyegujci6l7p.png" alt="1,157 users successfully imported in Active Directory" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;1,157 users successfully imported in Active Directory across six departments&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Department distribution:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Department&lt;/th&gt;
&lt;th&gt;Users&lt;/th&gt;
&lt;th&gt;Percentage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;711&lt;/td&gt;
&lt;td&gt;61.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engineering&lt;/td&gt;
&lt;td&gt;269&lt;/td&gt;
&lt;td&gt;23.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Executive&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;6.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finance&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;5.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;People&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;4.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Operations dominated because the AI dataset included many operational roles: specialists, managers, coordinators. This mirrors real organizations where operational staff outnumber engineers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 2-Hour Gotcha That Almost Broke Everything&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the mistake that cost me two hours: my first import succeeded—1,157 users created in AD, everything looked perfect. Sync ran. Users appeared in Entra ID. Victory, right?&lt;/p&gt;

&lt;p&gt;Wrong. The &lt;code&gt;Department&lt;/code&gt; field was completely empty in Entra ID. Not null. Just... blank. For every single user.&lt;/p&gt;

&lt;p&gt;I spent two hours thinking Entra Connect was broken. Checked attribute mappings. Re-ran sync. Read Microsoft docs. Questioned my career choices. Finally realized the source JSON didn't have a &lt;code&gt;department&lt;/code&gt; attribute—it only had &lt;code&gt;jobTitle&lt;/code&gt;. Entra Connect was working perfectly. It was faithfully syncing an empty field.&lt;/p&gt;

&lt;p&gt;The fix: extract department from job titles using pattern matching in PowerShell during import. Worked for 99% of cases. Edge cases got dumped into Operations as the default. Lesson learned: validate your source data BEFORE building infrastructure around it.&lt;/p&gt;

&lt;p&gt;Why does this matter? Try building conditional access policies based on department when every user's department is blank. Can't require MFA for Finance if you can't identify who's in Finance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entra Connect Configuration: Where Theory Meets Reality
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ob6gc5p4p06yg490i3j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6ob6gc5p4p06yg490i3j.png" alt="Downloading Entra Connect" width="800" height="118"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Downloading Entra Connect installer directly to the domain controller&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Authentication Method&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0z3fn95iccrz0fs584x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp0z3fn95iccrz0fs584x.png" alt="Selecting Password Hash Synchronization" width="800" height="542"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Selecting Password Hash Synchronization for simplest hybrid auth&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Three options: Password Hash Sync (PHS), Pass-through Authentication (PTA), or Federation. PHS wins for labs and many production scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity&lt;/strong&gt;: No additional infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience&lt;/strong&gt;: Cloud auth works even if on-premises AD is offline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt;: One-way hash sync, not passwords&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seamless SSO&lt;/strong&gt;: Transparent auth on domain-joined machines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;PTA needs agents online. Federation demands ADFS infrastructure. PHS just works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Service Account Permissions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuc3agxsej75hgth1xgbc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuc3agxsej75hgth1xgbc.png" alt="Creating service account with required permission" width="800" height="597"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Creating service account with required permissions for synchronization&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Let the wizard create the service account. It grants precisely the needed permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. UPN Suffix Mapping: The Confusing Part&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When Entra Connect analyzes your AD forest, it finds &lt;code&gt;@aiiam.local&lt;/code&gt;. The wizard warns: &lt;strong&gt;"UPN suffix 'aiiam.local' is not verified in Entra ID and cannot be added."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You'll see: &lt;strong&gt;"Continue without matching all UPN suffixes to verified domains."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most people panic here. "Continue without matching" sounds like you're about to break something critical. Here's what actually happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User in AD: &lt;code&gt;sarah.mitchell@aiiam.local&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Entra Connect reads this during sync&lt;/li&gt;
&lt;li&gt;Suffix &lt;code&gt;aiiam.local&lt;/code&gt; doesn't exist in Entra ID (&lt;code&gt;.local&lt;/code&gt; isn't routable)&lt;/li&gt;
&lt;li&gt;Entra Connect substitutes the default domain: &lt;code&gt;acme.onmicrosoft.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;User syncs to cloud as: &lt;code&gt;sarah.mitchell@acme.onmicrosoft.com&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both UPNs work. On-premises: &lt;code&gt;@aiiam.local&lt;/code&gt;. Cloud services: &lt;code&gt;@acme.onmicrosoft.com&lt;/code&gt;. Seamless SSO makes it transparent.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Critical&lt;/strong&gt;: Don't panic when you see "Continue without matching all UPN suffixes." Check it and move on.  Th    is is just saying proceed even though your AD UPN suffixes don’t match verified Entra domains. Microsoft best practise recommends adding a routable domain to AD.  This is the CORRECT configuration for hybrid environments with non-routable on-premises domains. &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;OU Filtering &amp;amp; Source Anchor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Configure sync to include only &lt;code&gt;AIIAM-Users&lt;/code&gt; OU. Uncheck everything else. This keeps admin accounts, computer objects, and default containers on-premises.&lt;/p&gt;

&lt;p&gt;Source anchor defaults to &lt;code&gt;mS-DS-ConsistencyGuid&lt;/code&gt;—don't change it. This immutable identifier permanently links on-premises objects to cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write-Back Configuration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I enabled password write-back because I wanted to see how it works in practice. This lets users reset their passwords via M365 self-service, and the change automatically syncs back to on-premises AD—genuinely useful for production. I skipped device write-back, group write-back, and user write-back to avoid complexity. Password write-back is low-risk (passwords flow one way: cloud → on-prem during resets). The others? Add them when you have a clear use case&lt;/p&gt;

&lt;p&gt;After config, sync starts immediately. First sync takes longer. Subsequent delta syncs every 30 minutes process only changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fafglj2ppzupwenzfftgh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fafglj2ppzupwenzfftgh.png" alt="Entra Connect sync scheduler" width="800" height="319"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Entra Connect sync scheduler showing 30-minute delta sync intervals&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The 30-minute interval means AD changes appear in Entra ID within half an hour. For testing: &lt;code&gt;Start-ADSyncSyncCycle -PolicyType Delta&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification: Confirming Hybrid Identity
&lt;/h2&gt;

&lt;p&gt;After initial sync (about 30 minutes), check Entra ID portal with filter: &lt;strong&gt;On-premises sync enabled == Yes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcvddiz9v1rxranubusb3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcvddiz9v1rxranubusb3.png" alt="Entra ID portal showing 1,158 user" width="800" height="507"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Entra ID portal showing 1,158 users with on-premises sync enabled&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Exactly 1,158 users—every AD import now exists in Entra ID. Click any user to see hybrid metadata:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdwh5n0rto64qrt4t6m6f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdwh5n0rto64qrt4t6m6f.png" alt="profile showing full hybrid identity attributes" width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Aaliyah Campbell's profile showing full hybrid identity attributes and sync details&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Job Information&lt;/strong&gt; (synced from AD):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Job title: Junior Software Engineer&lt;/li&gt;
&lt;li&gt;Department: Engineering&lt;/li&gt;
&lt;li&gt;Office location: London, UK&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;On-premises Sync Metadata&lt;/strong&gt; (hybrid identity fields):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-premises sync enabled&lt;/strong&gt;: Yes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises last sync date time&lt;/strong&gt;: 13 Mar 2026, 21:28&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises distinguished name&lt;/strong&gt;: &lt;code&gt;CN=Aaliyah Campbell,OU=AIIAM-Users,DC=aiiam,DC=local&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises immutable ID&lt;/strong&gt;: &lt;code&gt;A+NNCUrXmEKcpV8o3zqjjA==&lt;/code&gt; (Base64-encoded mS-DS-ConsistencyGuid)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises SAM account name&lt;/strong&gt;: aaliyah.campbell&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises user principal name&lt;/strong&gt;: &lt;a href="mailto:aaliyah.campbell@aiiam.local"&gt;aaliyah.campbell@aiiam.local&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises domain name&lt;/strong&gt;: aiiam.local&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinguished name confirms the OU structure synchronized correctly. The immutable ID links this cloud object permanently to the on-premises AD account—mess with this and you break the sync relationship. The dual UPN setup is right there: one for on-premises (&lt;code&gt;@aiiam.local&lt;/code&gt;), one for cloud (&lt;code&gt;@acem.onmicrosoft.com&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This metadata saves you when auth breaks at 2 AM. User can't log in? Check these fields first. They'll tell you immediately if sync failed, if the UPN got mangled, or if you're chasing a completely different problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing Dual UPN Authentication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both UPNs work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;aaliyah.campbell@aiiam.local&lt;/code&gt; for domain-joined systems&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aaliyah.campbell@acme.onmicrosoft.com&lt;/code&gt; for M365, Azure portal, and Entra ID apps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seamless SSO means domain users authenticate automatically to cloud services. No credential re-entry. The UPN transformation is invisible—they just log in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Learnings from Building at Scale
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UPN mapping is counterintuitive but works perfectly.&lt;/strong&gt; That warning about unmatched UPN suffixes? It's technically accurate but designed to terrify you. Microsoft's UI team made that checkbox sound like you're bypassing a critical safety check. You're not. "Continue without matching" isn't a workaround—it's literally the correct configuration for every hybrid environment with non-routable on-premises domains. Microsoft recommends you add a routable domain to avoid split identity problem. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistent disks are transformational.&lt;/strong&gt; First build: 3 hours manual setup. Terraform deployment: 8 minutes. Persistent disk strategy: destroy compute to save costs, rebuild in 10 minutes when needed. For intermittent labs, this is game-changing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Department fields matter.&lt;/strong&gt; Seemed optional until I thought about conditional access. Need MFA for Finance users accessing sensitive apps? Department field makes that policy trivial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scale reveals hidden issues.&lt;/strong&gt; With 5 test users, you don't notice that your import script has zero progress indicators. At 1,000 users, watching a silent PowerShell window for 8 minutes wondering if it crashed becomes maddening. You also discover fun things like LDAP query optimization, why bulk operations matter, and how network latency to your domain controller adds up when you're creating users one at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost optimization needs planning.&lt;/strong&gt; Standard_D2s_v3 running 24/7: £90/month. Auto-shutdown + manual startup: £5/month. Include the shutdown schedule in your Terraform config—not as an afterthought when you open your Azure bill and wonder why you're funding Microsoft's next datacenter expansion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON transformation between systems requires attention.&lt;/strong&gt; Entra ID's Graph API format differs from what &lt;code&gt;New-ADUser&lt;/code&gt; expects. Job title maps to &lt;code&gt;title&lt;/code&gt; not &lt;code&gt;jobTitle&lt;/code&gt;. Office location maps to &lt;code&gt;physicalDeliveryOfficeName&lt;/code&gt; not &lt;code&gt;officeLocation&lt;/code&gt;. Small mismatches cause silent failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Audit: Lab vs. Production Trade-offs
&lt;/h2&gt;

&lt;p&gt;Let's be clear: &lt;strong&gt;this deployment would get you fired in production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Domain controller with a public IP? RDP open to the entire internet? No MFA? Static passwords committed to version control? Any competent security team would shut this down before it hit production.&lt;/p&gt;

&lt;p&gt;And that's fine. This is a lab, not a production environment. But let's be honest about exactly what corners we're cutting and why they'd be catastrophic in the real world.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security Measures We Implemented
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Managed Identity for blob access (ended up using SAS token)&lt;/li&gt;
&lt;li&gt;Time-limited SAS tokens (1-hour expiry)&lt;/li&gt;
&lt;li&gt;Private blob storage&lt;/li&gt;
&lt;li&gt;Network Security Group rules&lt;/li&gt;
&lt;li&gt;Auto-shutdown to reduce attack surface&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Identity:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Password Hash Sync (more resilient than pass-through)&lt;/li&gt;
&lt;li&gt;Service account least privilege&lt;/li&gt;
&lt;li&gt;OU filtering (admin accounts stay on-premises)&lt;/li&gt;
&lt;li&gt;Immutable ID anchoring&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Security Concerns We Deliberately Ignored
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Network Exposure:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RDP open to 0.0.0.0/0&lt;/strong&gt;: Production should restrict RDP to known IP ranges or use Azure Bastion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public IP on domain controller&lt;/strong&gt;: Real deployments use site-to-site VPN or ExpressRoute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No network segmentation&lt;/strong&gt;: Production needs separate subnets for DCs, apps, and management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Authentication &amp;amp; Access:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No MFA enforcement&lt;/strong&gt;: Production Entra ID requires MFA for admins minimum, ideally all users&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static passwords in Terraform&lt;/strong&gt;: Sensitive values should use Azure Key Vault&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No conditional access policies&lt;/strong&gt;: Production needs location-based, device compliance, and risk-based policies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Privileged Identity Management&lt;/strong&gt;: Admin access should be just-in-time, not permanent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UPN Transformation&lt;/strong&gt;: One of the most common root causes of Microsoft 365 authentication incidents during hybrid migrations is keeping .local UPNs instead of aligning them before sync with Microsoft Entra Connect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Monitoring &amp;amp; Compliance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No audit logging&lt;/strong&gt;: Production needs Azure Monitor, Log Analytics, and SIEM integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No alerts on suspicious activity&lt;/strong&gt;: Failed auth, unusual locations, privilege escalation should trigger alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backup strategy&lt;/strong&gt;: Domain controller state should be backed up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No disaster recovery plan&lt;/strong&gt;: Production needs documented DR procedures and tested failover&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Compliance &amp;amp; Governance:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No encryption at rest&lt;/strong&gt;: Production should enforce Azure Disk Encryption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No data residency controls&lt;/strong&gt;: Compliance may mandate specific Azure regions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No change management&lt;/strong&gt;: Production changes need approval workflows, not direct &lt;code&gt;terraform apply&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why This Is Acceptable for Labs
&lt;/h3&gt;

&lt;p&gt;Labs prioritize demonstration/learning and cost over defence-in-depth. RDP from anywhere lets you connect without VPN complexity. Static passwords simplify Terraform demos. No MFA reduces testing friction.&lt;/p&gt;

&lt;p&gt;The goal is understanding how hybrid identity works, not building a production fortress. You're experimenting, breaking things, fixing them. That's harder when every action needs MFA approval and change management tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But&lt;/strong&gt;: Understanding what you're skipping is critical. Every ignored control is a production requirement. The transition from lab to production isn't scaling up—it's hardening security.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making This Safer
&lt;/h3&gt;

&lt;p&gt;How would you harden this?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network:&lt;/strong&gt; Replace NSG &lt;code&gt;0.0.0.0/0&lt;/code&gt; with specific IPs, deploy Azure Bastion, implement Azure Firewall&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Identity:&lt;/strong&gt; Enable MFA, implement conditional access, configure PAWs, use Identity Protection&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring:&lt;/strong&gt; Stream logs to Sentinel, configure anomaly alerts, enable Cloud App Security&lt;/p&gt;

&lt;p&gt;You can progressively add these controls. Each layer teaches you something.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What else would you add?&lt;/strong&gt; Drop your hardening recommendations in the comments. Someone always thinks of the control you missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next:
&lt;/h2&gt;

&lt;p&gt;We've built the foundation: AD infrastructure, 1,158 users imported, hybrid sync to Entra ID. These identities authenticate seamlessly across on-premises and cloud resources.&lt;/p&gt;

&lt;p&gt;Next part adds Conditional Access and security monitoring: Conditional Access based on location, Azure Event Hub streaming AD sign-in events, Stream Analytics for anomaly detection. The 1,158-user dataset provides sufficient volume for meaningful security telemetry.&lt;/p&gt;

&lt;p&gt;The identity foundation is in place. Now we monitor and detect threats.&lt;/p&gt;

&lt;p&gt;Questions about hybrid identity at scale? Drop them in the comments.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>activedirectory</category>
      <category>identity</category>
      <category>iam</category>
    </item>
    <item>
      <title>Cost-Effective IAM Lab Infrastructure for Enterprise-Scale Testing</title>
      <dc:creator>Christian</dc:creator>
      <pubDate>Thu, 12 Mar 2026 12:01:17 +0000</pubDate>
      <link>https://forem.com/chrisiam/cost-effective-iam-lab-infrastructure-for-enterprise-scale-testing-321p</link>
      <guid>https://forem.com/chrisiam/cost-effective-iam-lab-infrastructure-for-enterprise-scale-testing-321p</guid>
      <description>&lt;p&gt;Build a realistic Identity &amp;amp; Access Management lab with 1,606 AI-generated employees, multi-cloud infrastructure, and Terraform teardown strategies that keep costs under $1."&lt;/p&gt;

&lt;p&gt;Most IAM tutorials use toy datasets—10 users, simplified scenarios, no real infrastructure. &lt;br&gt;
That's fine for learning basics, but useless for testing ML detection systems at production scale.&lt;/p&gt;

&lt;p&gt;The problem: Machine learning needs realistic data volumes. A single attack among 10 users &lt;br&gt;
represents 10% of your dataset. Among 1,606 users generating 31,644 events, 69 attacks &lt;br&gt;
represent 0.218%—an enterprise-realistic attack rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I built:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,606 AI-generated employees with realistic organizational structure&lt;/li&gt;
&lt;li&gt;45 security groups (departments, seniority, functional roles)&lt;/li&gt;
&lt;li&gt;Behavioral metadata (devices, work patterns, VPN usage)&lt;/li&gt;
&lt;li&gt;Multi-cloud data pipeline (Azure Blob → Neo4j → Entra ID)&lt;/li&gt;
&lt;li&gt;Total cost: $0.21 using Terraform teardown strategy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This foundation supports ML training, attack simulation, and behavioral analytics—all at &lt;br&gt;
enterprise scale without enterprise costs.By the end, you'll have infrastructure that supports realistic attack simulation, graph-based privilege analysis, and behavioral anomaly detection.&lt;/p&gt;


&lt;h2&gt;
  
  
  What You'll Build (Step-by-Step Roadmap)
&lt;/h2&gt;

&lt;p&gt;Here's our implementation plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Step 1:&lt;/strong&gt; Design Terraform infrastructure for teardown efficiency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2:&lt;/strong&gt; Generate 1,606 realistic employees using Gemini API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 3:&lt;/strong&gt; Create realistic behavioral attributes (not just names)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 4:&lt;/strong&gt; Deploy multi-cloud data pipeline (Azure Blob, Neo4j, Entra ID)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 4:&lt;/strong&gt; Cost optimization and actual spend analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each step builds on the previous, creating a complete IAM lab environment suitable for ML training, attack simulation, and detection validation.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 1: Design Terraform Infrastructure for Teardown Efficiency
&lt;/h2&gt;

&lt;p&gt;The key to keeping costs under $1 is &lt;strong&gt;aggressive teardown&lt;/strong&gt;. Cloud resources cost money every hour they exist. My strategy: deploy infrastructure only when needed, destroy it immediately after data upload, and rely on Azure Blob Storage (pennies per month) for persistence.&lt;/p&gt;

&lt;p&gt;Here's the complete Terraform configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/main.tf&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_resource_group"&lt;/span&gt; &lt;span class="s2"&gt;"identity_lab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rg-ai-iam-identity-lab"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"uksouth"&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Project&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AI-IAM-Engineering"&lt;/span&gt;
    &lt;span class="nx"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Demo"&lt;/span&gt;
    &lt;span class="nx"&gt;ManagedBy&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Terraform"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_storage_account"&lt;/span&gt; &lt;span class="s2"&gt;"data_lake"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.prefix}datalake${random_string.unique_suffix.result}"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity_lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity_lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;account_tier&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard"&lt;/span&gt;
  &lt;span class="nx"&gt;account_replication_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LRS"&lt;/span&gt;  &lt;span class="c1"&gt;# Cheapest option&lt;/span&gt;
  &lt;span class="nx"&gt;account_kind&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StorageV2"&lt;/span&gt;
  &lt;span class="nx"&gt;min_tls_version&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TLS1_2"&lt;/span&gt;
  &lt;span class="nx"&gt;access_tier&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hot"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_storage_container"&lt;/span&gt; &lt;span class="s2"&gt;"synthetic_identities"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"synthetic-identities"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_account_name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_lake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;container_access_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_virtual_network"&lt;/span&gt; &lt;span class="s2"&gt;"identity_lab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.prefix}-vnet"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity_lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identity_lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;address_space&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.1.0.0/16"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this approach matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The critical Terraform configuration is the &lt;code&gt;prevent_deletion_if_contains_resources = false&lt;/code&gt; flag in the AzureRM provider. This allows &lt;code&gt;terraform destroy&lt;/code&gt; to clean up everything in one command, even if resources remain. No orphaned resources = no surprise bills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment workflow:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init
terraform apply   &lt;span class="c"&gt;# Deploy infrastructure&lt;/span&gt;
&lt;span class="c"&gt;# ... upload data to blob storage ...&lt;/span&gt;
terraform destroy &lt;span class="c"&gt;# Tear down everything except blob storage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Storage account remains (costs ~$0.20/month), VNet gets destroyed (saves ~$5/month). Data persists in blob storage for future use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt; Infrastructure deploys in 2 minutes, destroys in 1 minute. Storage costs $0.20/month ongoing. VNet and compute resources only exist during active testing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Generate 1,606 Realistic Employees Using Gemini API
&lt;/h2&gt;

&lt;p&gt;Realistic identities require diverse names, proper job titles, organizational hierarchy, and believable security group assignments. I used &lt;strong&gt;Gemini 2.0 Flash&lt;/strong&gt; (free tier) to generate all 1,606 employees through structured prompting.&lt;/p&gt;

&lt;p&gt;Gemini generates realistic names, but across 161 API calls, duplicates emerge. &lt;br&gt;
My solution: track all generated names globally and exclude them from future prompts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate batch 1 (10 employees) → store names in &lt;code&gt;self.used_names&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Generate batch 2 → pass 50 random previously used names to exclude&lt;/li&gt;
&lt;li&gt;Repeat for all 161 batches&lt;/li&gt;
&lt;li&gt;Result: 100% unique names across 1,606 employees&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without this, you'd get 20-30 duplicate "John Smith" entries that break Entra ID imports &lt;br&gt;
(UPNs must be unique).&lt;/p&gt;

&lt;p&gt;The challenge: &lt;strong&gt;ensuring uniqueness across 161 API calls&lt;/strong&gt; (10 employees per batch). Gemini can accidentally generate duplicate names across batches. The solution: global name tracking with AI-guided exclusions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# generate_employees_universal.py (core logic)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;google.generativeai&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="n"&gt;BATCH_SIZE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="n"&gt;TENANT_DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;laplace90210gmail.onmicrosoft.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EntraIDEmployeeGenerator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_domain&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_count&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;genai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GenerativeModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gemini-2.0-flash&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Global uniqueness tracking
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used_upns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;employees_in_batch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate prompt with anti-duplicate instructions&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;name_exclusions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used_names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;sample_names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used_names&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;name_exclusions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;AVOID THESE NAMES: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sample_names&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

        &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;employees_in_batch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; realistic UK employees for ACME Corporation.

CRITICAL: Each employee must have a COMPLETELY UNIQUE name.
- Mix British, Indian, African, Chinese, Middle Eastern, European names
- Use varied surnames: Smith, Patel, Khan, O&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Connor, Chen, etc.
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name_exclusions&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;

Requirements:
1. Diverse UK names reflecting modern Britain
2. Realistic job titles matching seniority distribution
3. UK cities: London, Manchester, Birmingham, Edinburgh, Leeds, Bristol
4. 100% MFA enabled
5. Only Executives/VPs get admin access

Return ONLY JSON array with exact structure...&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this approach works:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Traditional data generation tools create bland, unrealistic identities. Gemini understands context: "Senior Security Engineer" gets appropriate security groups, "Junior Engineer" doesn't get admin access, VPs get E5 licenses while juniors get E3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution achieved:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Departments:&lt;/strong&gt; Engineering (40%), Security (15%), Operations (20%), Finance (10%), People (10%), Executive (5%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seniority:&lt;/strong&gt; Executive (0.5%), VP (0.5%), Director (2%), Manager (7%), Senior (20%), Mid (40%), Junior (30%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security groups:&lt;/strong&gt; 45 total (6 departments + 7 seniority levels + 32 functional groups)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;API efficiency:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total API calls: 161 (1,606 ÷ 10 per batch)&lt;/li&gt;
&lt;li&gt;Rate limiting: 4 seconds between calls&lt;/li&gt;
&lt;li&gt;Total generation time: ~11 minutes&lt;/li&gt;
&lt;li&gt;Cost: $0.00 (Gemini free tier)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Facngnasgbxech73yvf48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Facngnasgbxech73yvf48.png" alt="Json file output" width="800" height="900"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt; 1,606 unique employees with realistic organizational structure, ready for Entra ID import. File size: 1.1 MB (&lt;code&gt;organization_1606_entra.json&lt;/code&gt;).&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 3: Creating Realistic Behavioral Attributes
&lt;/h3&gt;

&lt;p&gt;It's not enough to generate names and job titles. For realistic IAM testing, &lt;br&gt;
you need behavioral patterns that mirror real organizations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I added to each employee:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Consistent devices:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each employee assigned 1 primary device (OS + browser)&lt;/li&gt;
&lt;li&gt;95% of logins use primary device, 5% use secondary&lt;/li&gt;
&lt;li&gt;Executives more likely to use mobile (iOS/Safari)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Work pattern metadata:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Work hours by seniority (Executives: 7am-8pm, Juniors: 9am-5pm)&lt;/li&gt;
&lt;li&gt;Weekend work probability (Executives: 40%, Juniors: 2%)&lt;/li&gt;
&lt;li&gt;Location consistency (London-based employees rarely VPN from Manchester)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;VPN usage patterns:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;80% of employees in VPN-Users group&lt;/li&gt;
&lt;li&gt;VPN users get private IPs (10.x.x.x) 60% of the time&lt;/li&gt;
&lt;li&gt;Non-VPN users always get public IPs mapped to office locations&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Group membership logic:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Everyone in their department group&lt;/li&gt;
&lt;li&gt;Seniority-based groups (Junior-Level, Senior-Level, etc.)&lt;/li&gt;
&lt;li&gt;Functional groups (MFA-Enabled: 100%, Admin-Access: 3%)&lt;/li&gt;
&lt;li&gt;VIP users (5+ groups) represent 0.5% of population&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why this matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you eventually test ML detection systems, these patterns are what &lt;br&gt;
the models learn. Random data = models can't distinguish normal from abnormal.&lt;br&gt;
Realistic patterns = models can identify true anomalies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code example:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assign_device&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seniority&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Assign realistic device based on seniority&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seniority&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Executive&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;VP&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="c1"&gt;# Executives more likely to use mobile
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;os&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iOS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;browser&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Safari 17&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Most employees use desktop
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;os&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Windows 11&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;macOS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;browser&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Chrome 120&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Edge 120&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Firefox 121&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Device consistency: 95% (matches real employee behavior)&lt;/li&gt;
&lt;li&gt;Weekend work distribution: Realistic by seniority&lt;/li&gt;
&lt;li&gt;VPN usage: 80% of employees (typical for remote-hybrid orgs)&lt;/li&gt;
&lt;li&gt;VIP users: 8 employees with 5+ group memberships&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Deploy Multi-Cloud Data Pipeline (Azure Blob, Neo4j, Entra ID)
&lt;/h2&gt;

&lt;p&gt;Generated identities need to flow through three systems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Azure Blob Storage&lt;/strong&gt; – Long-term persistence (cheap)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neo4j Graph Database&lt;/strong&gt; – Privilege escalation path analysis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Entra ID&lt;/strong&gt; – Real identity provider for authentication testing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each system serves a different purpose. Blob storage is the source of truth. Neo4j enables graph queries like "find all privilege escalation paths from junior engineers to admin access." Entra ID provides real OAuth tokens for testing authentication flows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure Blob Upload:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# upload_to_blob.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;azure.storage.blob&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BlobServiceClient&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DataLakeUploader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connection_string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blob_service_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;BlobServiceClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_connection_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;connection_string&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blob_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;blob_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;blob_service_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_blob_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;synthetic-identities&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;blob_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;blob_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload_blob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;overwrite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;blob_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Neo4j Graph Load:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# load_to_neo4j.py (simplified)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;neo4j&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;GraphDatabase&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_organization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Create employee nodes
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;emp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
                CREATE (e:Employee {
                    employee_id: $id,
                    name: $name,
                    department: $dept,
                    seniority: $seniority,
                    admin_access: $admin
                })
            &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;employee_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="n"&gt;dept&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;department&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="n"&gt;seniority&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;seniority&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                 &lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;admin_access&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="c1"&gt;# Create group relationships
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;emp&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;employees&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
                &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
                    MATCH (e:Employee {employee_id: $id})
                    MERGE (g:Group {name: $group})
                    CREATE (e)-[:MEMBER_OF]-&amp;gt;(g)
                &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;emp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;employee_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why Neo4j matters:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Graph databases excel at relationship queries. Finding privilege escalation paths in SQL requires recursive CTEs and performs poorly. In Neo4j:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Find paths from junior employees to admin access&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;junior:&lt;/span&gt;&lt;span class="n"&gt;Employee&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;seniority:&lt;/span&gt; &lt;span class="s1"&gt;'Junior'&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:MEMBER_OF&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;admin:&lt;/span&gt;&lt;span class="n"&gt;Group&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;admin.name&lt;/span&gt; &lt;span class="ow"&gt;CONTAINS&lt;/span&gt; &lt;span class="s1"&gt;'Admin'&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This query runs in milliseconds and reveals 43 potential privilege escalation paths invisible to traditional analytics.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuvex2q4sh3603jf57aon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fuvex2q4sh3603jf57aon.png" alt="Entra Directory " width="431" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq2nnqciyz04ljnau7osw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq2nnqciyz04ljnau7osw.png" alt="Neo4J" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Results:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure Blob: 2 files uploaded (organization + groups), 1.15 MB total&lt;/li&gt;
&lt;li&gt;Neo4j: 1,606 Employee nodes, 45 Group nodes, 6,424 MEMBER_OF relationships&lt;/li&gt;
&lt;li&gt;Entra ID: (Optional) Real identity provider available for OAuth testing&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 5: Cost Optimization and Actual Spend Analysis
&lt;/h2&gt;

&lt;p&gt;The complete infrastructure ran for 2 weeks during development. Here's the actual cost breakdown:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure Costs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Storage account (blob): $0.16&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Azure total: $0.21&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Other Costs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Gemini API (161 calls): $0.00 (free tier)&lt;/li&gt;
&lt;li&gt;Neo4j Aura (free tier): $0.00&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-Azure total: $0.00&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total project cost: $0.21&lt;/strong&gt; (rounding up to $0.21 for safety margin)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost optimization strategies that worked:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Terraform teardown immediately after uploads&lt;/strong&gt; – VNet costs $5/month if left running. Destroyed after 3 days = $0.50 saved.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Blob storage only&lt;/strong&gt; – After initial upload, only blob storage remains. 1.15 MB costs $0.02/month.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gemini free tier&lt;/strong&gt; – 1,500 free requests/day. 161 batches easily fits within free tier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Neo4j Aura free tier&lt;/strong&gt; – 200k nodes/1M relationships included free. 1,606 employees well under limit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;UK South region&lt;/strong&gt; – Cheapest Azure region in Europe (vs. West Europe).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What $0.21 Buys You vs. Commercial Alternatives
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;This Lab ($0.21):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,606 employees, 45 groups, 30 days behavioral data&lt;/li&gt;
&lt;li&gt;Multi-cloud infrastructure (Azure + Neo4j + Entra ID)&lt;/li&gt;
&lt;li&gt;Full control over data generation and attack scenarios&lt;/li&gt;
&lt;li&gt;Scales to 10,000+ users by changing one variable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Commercial IAM Training Labs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pluralsight/Cloud Academy IAM courses: $29-49/month subscription&lt;/li&gt;
&lt;li&gt;Pre-built datasets (50-100 users max)&lt;/li&gt;
&lt;li&gt;No customization, no attack injection&lt;/li&gt;
&lt;li&gt;No infrastructure provisioning practice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Azure Training Environments (without teardown):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Running VNet + VM 24/7: ~$150/month&lt;/li&gt;
&lt;li&gt;Most tutorials ignore cost optimization entirely&lt;/li&gt;
&lt;li&gt;Students rack up $500+ bills learning "free" cloud skills&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Terraform teardown reduced my cost by 99.3%.&lt;/strong&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8mzj8ktcn9ihl4i2dbjh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8mzj8ktcn9ihl4i2dbjh.png" alt="Cost breakdown after deployment" width="782" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What costs would look like without optimization:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;VNet running 30 days: $5.00&lt;/li&gt;
&lt;li&gt;Premium blob storage: $2.50&lt;/li&gt;
&lt;li&gt;Neo4j paid tier: $65/month&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unoptimized cost: ~$72/month&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teardown strategy reduced cost by &lt;strong&gt;99.3%&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: What You Built and What's Next
&lt;/h2&gt;

&lt;p&gt;You now have a production-ready IAM lab with &lt;strong&gt;1,606 AI-generated employees&lt;/strong&gt;, &lt;strong&gt;45 security groups&lt;/strong&gt;, and &lt;strong&gt;multi-cloud infrastructure&lt;/strong&gt; connecting Azure, Neo4j, and Entra ID. Total cost: &lt;strong&gt;$0.21&lt;/strong&gt; for initial deployment.&lt;/p&gt;

&lt;p&gt;This foundation supports realistic attack simulation (covered in my next post), ML-based anomaly detection, graph-based privilege escalation analysis, and behavioral analytics. The data is realistic enough that detection systems trained here translate to production environments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this unlocks:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Inject realistic attacks (impossible travel, compromised accounts, credential stuffing)&lt;/li&gt;
&lt;li&gt;Train ML models on 30+ days of behavioral login patterns&lt;/li&gt;
&lt;li&gt;Analyze privilege escalation paths through Neo4j graph queries&lt;/li&gt;
&lt;li&gt;Test detection systems against enterprise-scale data volumes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The infrastructure scales beyond this initial deployment. Need 10,000 users? Change one variable. Need 90 days of login history? Adjust the event generator. The teardown strategy keeps costs minimal regardless of scale.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This foundation is ready for behavioral simulation, attack injection, and ML &lt;br&gt;
detection testing.&lt;/em&gt;* I'll explore those areas in future posts as I continue &lt;br&gt;
building on this lab&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Code repository:&lt;/strong&gt; Available on request&lt;br&gt;
&lt;strong&gt;Questions?&lt;/strong&gt; Drop them in the comments.&lt;/p&gt;

</description>
      <category>iam</category>
      <category>azure</category>
      <category>terraform</category>
      <category>security</category>
    </item>
  </channel>
</rss>
