ANTALON'S HOME PAGE
PERSONAL HOME PAGE / SYSTEMS & CLOUD BUILD LOG

Antalon's corner of the web

Systems administration. Cloud projects. Things that broke.

· IAM & CLOUD PROJECTS · BUILT, BROKEN, TESTED ·
· @Antalon · IAM / Okta / Entra ID / Google Cloud Identity

Three directories, one source of truth.
Here's what I learned building it.

I've worked quite a bit with identity management, particularly Entra ID, SSO, and automated user provisioning. Most of that experience has been within Microsoft environments, and while I'm a big fan, I wanted to expose myself to some other tools and workflows. Luckily, IAM transfers conceptually, and each interface is just a different array of buttons.

So I built an environment where Okta is the source of truth for both Microsoft Entra ID and Google Cloud Identity. I didn't want to stop at getting a couple of SSO buttons working. I wanted to test the actual identity lifecycle: provisioning users, updating attributes, enforcing authentication policies, suspending accounts, and making sure the results were consistent across all three directories. Because why not go for gold?

I also wanted to see what kind of issues could arise from that setup e.g. What if a user already existed in one directory? What if the usernames didn't match? What happens if Okta goes down? And, most importantly, does disabling a user in Okta actually disable them in both downstream directories?

The environment runs entirely on free tiers: Okta Integrator Free, Entra ID Free, and Google Cloud Identity Free. So there were some limits with things like Conditional Access, PIM, and logging, but constraints are where creativity flourishes.

In the end, I got the provisioning, authentication, and deactivation flows working and verified them in each directory. The interesting part wasn't just that they worked. It was finding out how differently Entra and Google handle what looks like the same integration on paper.

One thing I didn't expect was how much access the standard provisioning connectors requested. Both integrations can do far more than create user accounts, which makes the connectors themselves part of the security boundary. That was very notable as a possible oversight, in my opinion.

[ READ MORE — THE BUILD, FAILURES & RESULTS ▼ ][ SHOW LESS ▲ ]

The architecture

The overall design is pretty straightforward. Okta owns the identity, provisions it into the other directories, and handles the authentication requirements.

Okta (source of truth + MFA) / \ / \ Microsoft Graph Google Admin SDK + WS-Fed + SAML / \ Entra ID Google Cloud Identity Terraform → Okta groups and authentication policies

The distinction I had to keep in mind was that provisioning and authentication are separate operations. Provisioning decides whether an account exists, what attributes it has, and whether it's enabled. Federation decides how that account signs in. One can work while the other is completely broken.

I also manage the Okta groups and authentication policies with Terraform, in okta-as-code, including plan-on-PR, apply-on-merge, and a daily drift check. I left the Microsoft and Google connectors out of this part. They carry authenticated tokens, consent grants, and provisioning state that I don't want an incomplete provider changing or replacing.

Getting identities to match

I went with a simple identity model: one person, one lowercase username, and the same address in all three directories. But, just for fun, some of them use random, capitalized letters. Mostly so I can try applying the transformation String.toLowerCase(user.login) to the integrations.

Entra uses userPrincipalName to match an existing account. Google uses primaryEmail. I also learned that I could verify glasscliff.online in Entra and add it to Google as a secondary domain. I assumed I would and bought a second domain ahead of time. It's in my back pocket for now, though.

Of course, I managed to demonstrate why matching matters almost immediately.

One account in Entra differed from its Okta counterpart by one letter. Instead of linking the account, Okta created another one. I'm going to take credit and say that was all according to plan. Just an intentional way to see exactly what would happen... Haha... ha... On purpose...

NOTE TO SELF: A documented naming convention isn't a control. Mismatches should be detected before provisioning creates a duplicate.

There's also a difference in how the vendors anchor identities. In this configuration, Okta placed its identifier into Entra's onPremisesImmutableId, which was interesting, but I didn't bother trying to change it despite having a hybrid homelab. No problem in seeing how that affects on-prem identities. Google's connector matches by email, without an equivalent exposed immutable anchor. So I don't think I can casually delete and recreate the Okta user and reattach it to the existing Entra account.

Okta to Microsoft Entra ID

On the Microsoft side, I used Okta's Office 365 integration with Create, Update, and Deactivate enabled. Provisioning goes through Microsoft Graph, password synchronization is off. As an aside, I was very happy to see that the free tenants actually have everything I'd need.

Authorizing the connector created the Okta Microsoft Graph Client service principal in Entra with its own set of application permissions. That's useful operationally, but the permissions themselves are worth looking at:

RoleManagement.ReadWrite.Directory
AppRoleAssignment.ReadWrite.All
Application.ReadWrite.All
User.ReadWrite.All
Group.ReadWrite.All
GroupMember.ReadWrite.All
Organization.Read.All
Application.Read.All

A few of those are highly privileged. In particular, I was looking at RoleManagement.ReadWrite.Directory. That role is capable of assigning directory roles, including Global Administrator. That's a looooot of standing access for a user provisioning connector. Not an issue here, just an eyebrow raiser.

Microsoft's consent prompt doesn't offer individual permission checkboxes. Reducing that privilege is a separate hardening thing that I still want to test.

Federating the domain

For authentication, I configured WS-Federation. Okta used its Graph authorization to change the authentication type of glasscliff.online from Managed to Federated. From that point on, users on the federated domain are redirected to Okta to authenticate.

Before that, though, I tested my break glass account for my own anxiety. I verified the result through Graph instead of the GUI. Okta also configured federatedIdpMfaBehavior as enforceMfaByFederatedIdp, so Entra accepts MFA performed upstream by Okta.

The signing certificate was another detail I checked. Mine had a ten-year validity period, but nextSigningCertificate was null. More immediately, the certificate is associated with the Okta app instance. Deleting and recreating that instance can break the trust long before its certificate expires.

Okta to Google Cloud Identity

The Google connection uses its Admin SDK Directory API for provisioning and SAML 2.0 for authentication. The basic goal is the same, but the controls and defaults are very different.

I enabled Create, Update, and Deactivate. I also disabled inbound imports. If Okta is supposed to own the identities, I don't want my Google service and emergency accounts being sent over to Okta. I only have ten seats on there!

Organizational units actually affect authentication

Google uses organizational units as an important scope boundary. I created separate OUs for Okta-provisioned users, administrators, service accounts, and breakglass.

Google OUSSO assignment
/Okta UsersOkta SAML profile
/AdministratorsNone — Google sign-in
/Service AccountsNone — Google sign-in
/BreakglassNone — Google sign-in

I also had to adjust Google's post-SSO verification setting for the Okta users. Without that, Google could accept the SAML response and then demand a phone number. Which would be fine if I had ten phones, but I do not. Entra's federation setup already trusted the MFA handled by Okta, Google needed an additional OU-level change.

Attribute mapping can remove data

For one soft-match test, I created a Google account with slightly different values than its Okta counterpart, then pushed the identity from Okta. The account linked successfully without creating a duplicate and the attribute results were corrected:

AttributeBeforeAfter push
First nameDurelDurell
Job titleSoldierEmpty
DepartmentFirst InfantryEmpty
OU//Okta Users

The first name was corrected and the OU changed as expected. The other two fields were cleared, because Okta didn't have a title or department value for that user and I'd configured those fields for updates.

NOTE TO SELF: An attribute mapping is an ownership decision. If Okta owns a field, an empty value in Okta can erase a populated value downstream. Color me surprised.

Enabling Update User Attributes also changed existing mappings from Create to Create and Update in one move. That's something I would review carefully before turning on in an established environment.

Google's connector also requested substantial OAuth scopes, including admin.directory.rolemanagement and admin.directory.user. In combination, those can provide a route to full tenant control. But unlike Entra, Google Workspace has per-scope consent checkboxes, so permission reduction is at least directly testable.

Planning for Okta being unavailable

Centralizing authentication also creates a central dependency. Being locked out an entire tenant scares me, I've heard many horror stories regarding it. If Okta is misconfigured, compromised, or unavailable, I still need a way to log into Entra and Google Workspace.

For Entra, I created a cloud-only emergency Global Administrator on the original .onmicrosoft.com domain, which isn't federated. It has independent credentials and MFA. I ensured to grant this account full subscription management access as well; tenant recovery and subscription recovery are separate steps.

For Google, I created a super administrator outside the federated OU, with independent authentication and offline recovery materials.

Google complicated the account's first sign-in with its login challenge demanding a phone number (as mentioned before. This is actually the part that made me disable it). Password resets didn't solve it. I used my primary super administrator to temporarily turn off the login challenge long enough to initialize the account until I found out how to disable it entirely.

NOTE TO SELF: A break-glass account isn't useful just because it exists in the console. In production, test the actual sign-in path.

I keep them separate from the accounts used to authorize the connectors. In a live environment, these accounts would need to be tested quarterly and around federation changes.

Things that broke during testing

An Okta app tile isn't proof of federation

The Google app initially used Secure Web Authentication, which can store and replay a password. From the user's seat, clicking the tile can look like SSO even though it isn't SAML federation. I explicitly switched to SAML and verified the target's SSO configuration instead of treating a successful tile launch as evidence.

Microsoft returned AADSTS90019

Launching Microsoft from the Okta dashboard returned AADSTS90019. Okta's logs showed successful policy evaluation and an SSO response. Microsoft was rejecting the IdP-initiated request because the expected tenant context was missing.

Starting at Microsoft's login page and letting home realm discovery redirect to Okta worked immediately. That told me the federation itself was functional (relieved) and the problem was in the launch path, not a reason to rebuild the trust.

Okta rejected a valid Google organizational unit

After changes to Google's OUs, provisioning occasionally produced an error including:

Invalid data value for REFERENCE 'orgUnitPath'
(type=GoogleOrganizationUnit)

It appeared related to Okta's cached Google OU references because running Force Sync correlated with the issue clearing twice, though I haven't fully verified that it was the mechanism refreshing the cache.

Google's reporting error pointed at DNS

Google told me a report wasn't available because my domain might not be verified or the edition might not support it. The domain was verified; the free edition simply didn't include that reporting feature :(.

User status wasn't a reliable authentication indicator

I also found users with passkeys could authenticate while Okta showed their password status as PASSWORD_EXPIRED. Although this ended up being a misconfigured authentication policy, I did note that a password related status doesn't necessarily tell you whether a user is in a working state. That's important for any reconciliation dashboard I might build later.

Does deprovisioning actually work?

This was the test that mattered most to me. I deactivated a user in Okta, then checked both downstream directories independently.

DirectoryObserved state
OktaDEPROVISIONED
Entra IDaccountEnabled: false
GoogleAccount suspended

One action in Okta disabled the identity in both downstream directories. Neither user object was deleted, and the existing identifiers were retained. Success on the first try!

Rollback needs more than a switch

Another thing I wanted to understand was what rollback would actually look like if federation failed.

Simply removing SSO doesn't restore users to a known, good state. Okta-created users can have passwords generated during provisioning that nobody knows, and I've disabled password sync. Remove federation and those users might have no usable local credential.

For an actual rollback, I'd need to remove the SSO assignment or federation and reset the affected users' passwords, distribute credentials out of band, and restore the appropriate verification requirements.

Provisioning rollback is simpler: disable the connector operations or revoke authorization while keeping existing accounts in place.

NOTE TO SELF: Reversing the setting you changed doesn't necessarily restore service. Know what the user will authenticate with afterward.

What I'm still working on

The main flows are working, but this isn't a claim that every edge case or security control has been completed. The most important remaining task is reducing the standing privileges on both provisioning connectors and verifying that the integrations continue to work.

  • Build reconciliation across Okta, Entra, and Google. Done since this was written: a twice-daily, read-only reconciliation.
  • Test whether an Okta Suspend event propagates the same way as Deactivate.
  • Determine whether Google's downstream password is regenerated on every profile push or just at creation.
  • Import the groups, authentication policies, and management into Terraform. As it stands, I currently have Terraform managing another policy. I plan to implement this for these as well as a .5 addition to this project by the end of the week.
  • Test connector scope reduction.
  • Find a way to monitor and alert for Breakglass events (if the free tiers can handle that.)

I may have to buy licensing just to play around with more settings. No Entra P1 means no Conditional Access, PIM, or the diagnostic export options I'd use in a licensed enterprise tenant. Google Cloud Identity Free also limits reporting.

What I took away from the project

In theory, this is a simple architecture: Okta creates and manages identities in two directories, and both directories trust it to authenticate their users.

What made it useful was everything around that: Entra and Google disagree about identifiers, identity anchors, permission consent, federation defaults, and post-SSO challenges. I encountered destructive attribute updates, a duplicate identity, a misleading application tile, a federation error isolated to IdP-initiated sign-in, and a rollback path that could have left everybody without working credentials.

I ended up with something functional: I can create and manage users in Okta, provision them into Entra and Google, require phishing-resistant authentication, and disable the identities from one place.

More importantly, I have a better picture of the trust boundaries, what I'd have to account for before doing something similar in production, and inter-platform identity management. And that's all I really wanted out of it.

Built and verified 10/09/26 - 10/12/26. Additional hardening and edge case testing to come.

↑ back to top

· @Antalon · Cloud Resume Challenge / Azure

I built my resume on Azure with Terraform.
Here's everything that broke.

My resume now lives at glasscliff.online. The page runs on Azure, the infrastructure is defined in Terraform, and the visitor counter calls a Python API backed by Cosmos DB. A push to GitHub runs the tests before anything gets deployed.

That’s the finished version. Getting there involved a retired service, a subscription policy, a disappearing validation token, and a Python environment that wasn’t the one I thought I was using.

This was my Cloud Resume Challenge build. I used an Azure for Students subscription with $100 in credit, so cost wasn’t something I could leave until later. If the credit runs out, the subscription switches off. That constraint shaped a lot of the architecture.

This isn’t a walkthrough of every setup step. It’s a build log of the problems I actually ran into, what I changed, and what I’d check earlier next time.

The setup

The resume and API live in one Static Web App. Azure DNS handles the domain, the page calls /api/counter, and the function updates the count in Cosmos DB. GitHub Actions ships the application code; Terraform defines the infrastructure.

Browser → Azure DNS → Static Web Apps ├─ Resume page └─ /api/counter → Cosmos DB (Table API) GitHub Actions → tests → application deployment Terraform → Azure infrastructure
[ READ MORE — THE BUILD, FAILURES & RESULTS ▼ ][ SHOW LESS ▲ ]

01 The tutorial was already out of date

The Azure version of the challenge starts with a Storage account behind Azure CDN. That sounded straightforward until I tried to build it. Azure CDN (classic) stopped accepting new profiles on August 15, 2025. The replacement I looked at, Front Door Standard, had a base fee of about $35 a month.

I had $100 in student credit. Spending more than a third of that every month to serve a resume wasn’t happening.

I went with Azure Static Web Apps on the Free plan instead. It hosts the page, handles managed TLS for the custom domains, and runs the Python API under /api. The page and API share an origin, so I didn’t have to add CORS configuration just to update a counter.

There were tradeoffs. Managed functions support HTTP triggers, which was fine for this project. There’s no managed identity for the managed API, so Terraform supplies the database connection string through an app setting. The plan allows two custom domains, which covered the apex and www.

NOTE TO SELF: Check whether the services in a tutorial are still available before planning the build around them.

↑ back to top

02 A 403 sent me looking in the wrong place

My first terraform apply for the Static Web App failed with RequestDisallowedByAzure. My first instinct was to look at permissions. I owned the subscription, though, and this turned out to be a policy restriction rather than the RBAC problem I initially suspected.

Azure for Students had an “Allowed resource deployment regions” policy. South Central US was allowed by the subscription, but it wasn’t a region where I could deploy Static Web Apps. I needed a region that appeared on both lists: allowed by the policy and supported by the service.

I also had some cleanup to do around DNS. My DNS zone was in my default subscription, while the project was in the student subscription. I moved the zone into the project subscription so the Terraform configuration could use a single provider.

Then I got a “DNS zone not found” error. The zone had moved. My variables still named the old resource group.

NOTE TO SELF: Check policy as well as permissions. And when a resource moves, update the references—not just the resource.

↑ back to top

03 Moving the old site without pulling the plug

glasscliff.online was already serving my portfolio from an App Service. I wanted the resume at the root, but I didn’t want to throw away the old site. The plan was to move it to projects.glasscliff.online and link to it from the resume.

This was mostly an exercise in doing things in the right order.

  1. I set up the portfolio’s new address first: the asuid TXT record for domain verification, a CNAME for projects, and a managed certificate.
  2. The certificate binding initially failed with an empty thumbprint. Certificate issuance was still happening in the background. I polled until the certificate existed, then bound it.
  3. I checked that the portfolio loaded at its new address before touching the apex.

When I finally went to move the apex, Terraform refused to create the A and CNAME records. Records with those names already existed.

Deleting them just so Terraform could recreate them would have interrupted the working site. Instead, I used terraform import to bring the existing records into state, then let the next apply change their targets.

NOTE TO SELF: Infrastructure as code doesn’t mean everything starts empty. Adopt the working resources before you replace anything.

↑ back to top

04 The Terraform value that disappeared

This was probably the most interesting problem in the build.

Static Web Apps gives you a validation token to prove you own the domain. Terraform writes that token into a _dnsauth TXT record. The first apply worked. The next plan failed with an error about the expected length of record.0.value.

After domain validation succeeded, Azure cleared the token. My configuration was now trying to put an empty value into the TXT record.

I tried ignore_changes, but that wasn’t enough on its own. The expression still had to produce a valid value. Ignoring changes didn’t make an empty string valid.

The fix was to provide a fallback, then ignore changes to the record:

record {
  value = coalesce(
    azurerm_static_web_app_custom_domain.apex.validation_token,
    "validated"
  )
}

lifecycle {
  ignore_changes = [record]
}

The www domain had a different failure: CNAME Record is invalid.

I had typed the hostname into both the custom-domain resource and the DNS resource. The strings matched, but matching strings aren’t a Terraform dependency. Terraform could create both resources in parallel, which meant Azure could check for the CNAME before it existed.

I changed the custom-domain name to reference the DNS record directly:

domain_name = "${azurerm_dns_cname_record.www.name}.${azurerm_dns_cname_record.www.zone_name}"

That gave Terraform the dependency it needed to order the resources.

NOTE TO SELF: If one resource needs another, express that relationship in the configuration. Terraform can’t infer it from two strings that happen to match.

↑ back to top

05 The failed apply that had actually created something

The counter uses a Cosmos DB account with the Table API in serverless mode. For this project, request-based billing made more sense than paying for capacity I wasn’t going to use.

The first apply failed with ServiceUnavailable. I treated it as a regional capacity issue and made the Cosmos region a separate variable, with East US 2 as the next region to try.

Before retrying, I checked Azure. Creating a Cosmos account is a long-running operation, and the CLI showed the account in a Succeeded state even though Terraform had reported a failure.

That changed the next step. Instead of blindly trying to create it again, I imported the existing account into Terraform state and applied again. The table and connection-string configuration followed.

NOTE TO SELF: A failed apply is not a rollback. Check what exists before assuming nothing happened.

↑ back to top

06 My Python wasn’t the Python I thought it was

The API itself is small. A request to /api/counter reads the count, increments it, writes it back, and returns {"count": n}. The response includes Cache-Control: no-store so a cached response doesn’t stand in for the current count.

I kept the counter logic—next_count and bump—separate from the Azure Functions route handler. That let me test the logic with a fake table client instead of needing an Azure runtime or real credentials. The six unit tests ran in about 0.2 seconds.

Getting the right Python environment on Windows took longer than writing the tests.

  • python launched the Microsoft Store stub. py -3.11 found the interpreter I actually wanted.
  • My first virtual environment used Python 3.14 and resolved azure-functions to 2.3.0.
  • On Python 3.11—the runtime I was targeting in Static Web Apps—the dependency resolved to 1.25.0.

So I had passing local tests against a library version that wasn’t the one I would deploy. I pinned exact versions in requirements.txt and rebuilt the virtual environment on 3.11 to align local development, CI, and the deployment runtime.

Two calls to the live endpoint returned {"count": 4} and then {"count": 5}. The deployed counter was working.

There’s still a known limitation: the read-then-write operation isn’t atomic. Two simultaneous requests can read the same count and overwrite each other’s increment. The next fix is optimistic concurrency using the entity’s ETag, with conflict handling. For a cosmetic counter, I documented that limitation rather than expanding the first version.

NOTE TO SELF: “Works on my machine” includes the interpreter, not just the code. Match the runtime and pin the dependencies.

↑ back to top

Shipping it: a failing test stops the deploy

One GitHub Actions workflow handles the site and API. It runs when a push or pull request touches the site, API, or tests.

  1. The test job installs the pinned dependencies on Python 3.11 and runs pytest.
  2. The deploy job declares needs: test. It uploads the site and API to Static Web Apps only after the test job succeeds.

The deployment token comes from a Terraform output and goes into a GitHub repository secret, not into the application code.

Still on the list

Terraform still runs from my laptop. Next is moving plan and apply into GitHub Actions with OIDC federation, so the workflow can authenticate to Azure without a stored cloud credential. After that, I’ll add ETag-based concurrency handling to the counter.

The biggest takeaway wasn’t a particular Azure service or Terraform command. It was learning to check what actually happened instead of taking an error message as the whole explanation.

The 403 wasn’t the permissions problem I expected. The failed apply had created a resource. The matching hostnames didn’t establish a dependency. In each case, looking at the policy, the live resource, or Terraform’s references was more useful than rerunning the same command.

If I were starting this again, I’d check service availability and subscription restrictions first, line up the Python environments earlier, and inspect Azure after a failed apply before trying anything else. Those checks would have saved me some unnecessary troubleshooting.

The finished resume is at glasscliff.online. The previous portfolio is at projects.glasscliff.online.

End of entry. Infrastructure still subject to improvement.