azure-provision

v2026.09.24

Bootstrap Azure automation when interactive `az login` is blocked by a Conditional Access policy (error 530035, "Device state: Unregistered"). Mints a service principal through the Azure Portal session ONCE — using Microsoft Graph over a captured token, not DOM clicking — then provisions resource groups, Cognitive Services / AI Language / Speech resources and their keys entirely via az/REST, writing key AND endpoint together into the SCS vault. Use when az login fails but the portal works, when moving Azure resources to a client's own tenant, or when you need unattended Azure automation from a machine that is not Entra-joined. Triggers: "az login blocked", "AADSTS530035", "Conditional Access Azure CLI", "create Azure service principal", "migrate Azure account", "provision Azure AI resources", "Azure app registration".

GitHub
Install command
npx skhub add terrylica/azure-provision
Markdown
SKILL.md

azure-provision — service-principal bootstrap through a portal session

Azure's Portal and CLI are different Entra applications. A Conditional Access policy scoped to the CLI (04b07795-8ddb-461a-bbee-02f9e1bf7b46) leaves the portal (c44b4083-3bb0-49c1-b47d-974e53cbdf3c) working, and az login has no --client-id override — so when the CLI is blocked, the browser session is the only door. Walk through it once, mint a service principal, and every later run is unattended: client-credentials auth is not gated by user Conditional Access.

Self-Evolving Skill: the Azure Portal drifts. On real, reproducible breakage fix the scripts AND append to the Vendor Quirks log in ../dashboard-forge/SKILL.md — immediately, not later.

Why this exists

Observed 2026-08-02 on a real client migration:

Error Code: 530035        App name: Microsoft Azure CLI
Device state: Unregistered   App id: 04b07795-8ddb-461a-bbee-02f9e1bf7b46

…while portal.azure.com signed the same account in without complaint. Two traps followed, both worth knowing before you start:

  • az account list returned []. That is a consequence of the block, not evidence that no subscription exists. The portal showed an active subscription with the user as Owner.
  • The account was a guest (user_domain#EXT#@tenant.onmicrosoft.com). Guests get no app-registration rights by default — they need Application Developer/Administrator, and an RBAC role at the target scope. If minting fails with 403, that is the reason, and it needs a tenant admin, not a retry.

The design: browser for the login, Graph for the work

The first version of this drove the portal's DOM and it was a disaster — see the Portal Quirks below. The fix was not better selectors. The portal's own Graph token carries the scopes needed to do the whole job over REST, measured on a live session:

Application.ReadWrite.All · AppRoleAssignment.ReadWrite.All · RoleManagement.ReadWrite.Directory

So captureBearer(page, "graph.microsoft.com") off the wire, then plain HTTP. Zero selectors, zero blade navigation, nothing to drift. This is the plugin's Hybrid rule taken to its limit: the browser contributes a token, not clicks.

StepHowBrowser?
Sign in (incl. MFA)human, in the visible Chromeyes, once
Capture Graph + ARM tokenscaptureBearer() off real requestspassive
Identity preflightdecodeJwtClaims() → assertIdentity()no
Create app registrationPOST /v1.0/applicationsno
Create service principalPOST /v1.0/servicePrincipalsno
Create client secretPOST /v1.0/applications/{id}/addPasswordno
Assign ContributorPUT …/roleAssignments/{guid}?api-version=2022-04-01no
Everything afterwardsaz as the service principalno

Run it

cd plugins/web-forge/skills/azure-provision
node scripts/mint-sp.mjs specs/example.json     # browser opens; sign in; the rest is REST
node scripts/provision.mjs specs/example.json   # unattended: RG + resources + keys → vault

Both are idempotent (GET before POST). Re-running adopts what exists. The DOM version was not, which is exactly how a duplicate app registration got created.

Portal quirks (why there is no clicking here)

TrapWhat happens
Ambiguous accessible namesEvery close button is "Close content '<Blade Title>'", so getByRole("button", {name:/register/i}).first() hits close, not submit. It reports a successful click and creates nothing. Use clickExact(), which refuses and lists candidates
Two search boxesThe masthead search matches input[placeholder*="Search"] before any blade's own filter. Anchor on the specific placeholder
A list view can lieAfter a successful create, Owned applications said "This account isn't listed as an owner of any applications" — true for a guest creator, and utterly misleading. Ground truth is the notifications pane or All applications
Tokens are not in localStorageThis generation of MSAL SPA keeps them in memory. Capture off the wire
Cached blades issue no requestToken capture then silently yields null, which reads like "no permission". captureBearer retries with a reload

Verification — both directions, always

A key that returns HTTP 200 on a trivial call proves nothing. The acceptance checks are:

  1. SP authenticates AND sees the subscription. mint-sp.mjs polls the client-credentials flow directly (never az login -p <secret> — that puts the secret in argv, readable via ps). RBAC is eventually consistent: expect ~15–60s of "not yet". Do not read that as failure.
  2. Each provisioned resource DETECTS, not merely authenticates. For an AI Language resource, assert real entities come back from a synthetic probe — not just a 200.
  3. The refusal direction fires. Point the spec at a bogus subscription and confirm it fails loudly.

Key and endpoint must move together

provision.mjs writes key + endpoint (+ region) to the vault as one unit, and the schema requires it. This is not tidiness: a rotation that moved the key but left the endpoint as a hardcoded literal elsewhere produced HTTP 401 — Access denied due to invalid subscription key or wrong API endpoint in production. Emit both, read both from the same place.

Secrets discipline

  • Client secret: Graph returns it once → straight into vault set --stdin. Never argv, never stdout, never a screenshot.
  • Never screenshot a secret-reveal page. An agent that later reads that PNG puts the value into conversation context permanently. purgeShots() runs at the end of every run.
  • Captured ARM/Graph tokens are ~1h user credentials. Log decodeJwtClaims() output, never the token.
  • The profile dir (~/.local/share/web-forge/profile-azure) holds a live tenant-admin session — treat it as credential-equivalent.

Reference implementation

~/work/example-client — the 2026-08-02 migration of a client's Speech + Language resources from a personal Microsoft account into the client's own tenant, under a CA block. Decision record: that repo's docs/security-and-compliance/README.md.

Post-Execution Reflection

  1. Did any step need the DOM? If yes, ask whether a Graph/ARM call would have done it — that is almost always the better fix, and it is how this skill got to zero selectors.
  2. Did a portal behaviour surprise you? Append it to the dashboard-forge Vendor Quirks log.
  3. Did any secret reach argv, stdout, or a screenshot? If a reveal was captured, purge it and treat the secret as context-exposed.
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

plugins/web-forge/skills/azure-provision

Default branch

main

Latest commit

b657cca

Tree SHA

906e003