
Customer Identity and Access Management (CIAM) provides the identity foundation for applications used by customers: registration, sign-in, account recovery, federation with other identity providers, and the policies that protect those processes. Unlike workforce identity, which primarily serves employees and internal resources, CIAM must support people outside an organization’s administrative control. A customer portal, online shop or subscription service needs an accessible registration process while protecting accounts and personal information.
The importance of CIAM becomes apparent long before an application reaches millions of accounts. Password recovery, verification messages, authentication protocols and protection against account abuse are ongoing responsibilities. Implementing them separately in every application increases maintenance work and makes consistent security policies difficult. A dedicated identity platform centralizes these concerns and allows application teams to focus on their business workflows.
That separation has limits. The identity provider can establish who signed in and issue tokens; the application still has to validate those tokens and decide whether that person may access a particular order, contract or customer account. Authentication does not replace business authorization.
Several products address this space, with different operating models:
| Platform | Approach | Architectural consideration |
|---|---|---|
| Microsoft Entra External ID | Managed customer identity using external tenants | Fits an Azure environment with established Entra administration and Microsoft Graph tooling. |
| Auth0 | Managed identity platform for application authentication | Provides hosted authentication and integrations across application stacks. |
| Amazon Cognito | AWS identity service with user pools and identity pools | Separates application user authentication from access to AWS resources. |
| Keycloak | Open-source identity and access management | Supports self-managed identity, with responsibility for hosting, upgrades and availability. |
These are alternatives to evaluate against actual requirements, rather than interchangeable implementations. Relevant differences include supported identity providers, customization, regional availability, operational ownership and integration effort. The product foundations are documented in the Entra External ID overview , Auth0 overview , Amazon Cognito developer guide and Keycloak project documentation .
For Microsoft Entra External ID, a repeatable setup should include the customer-facing appearance as well as the directory itself. A tenant created through automation but branded manually still depends on undocumented portal changes.
A repeatable foundation for customer identity
The implementation described here uses a PowerShell setup script, a YAML configuration file and separate branding assets for development, staging and production. The examples use fictional names. The complete script and configuration are included below, alongside focused snippets explaining the individual operations.
The setup covers resource groups, external tenants, resource tags, deletion locks, verified tenant domains and company branding. Application registrations, user flows, federation, authentication policies and application integration are subsequent configuration steps.
The central design decision is to separate two management boundaries:
| Boundary | API | Responsibilities |
|---|---|---|
| Azure subscription associated with the workforce tenant | Azure Resource Manager (ARM) | Resource group, CIAM directory resource, tags and management lock |
| Newly created external tenant | Microsoft Graph | Domain verification, default domain and organizational branding |
The workforce tenant ID is an input to the setup. The external tenant ID is an output of provisioning. Reusing the workforce tenant ID for subsequent Graph requests would target the wrong directory.
In this implementation, each environment gets its own external tenant. This isolates customer accounts and tenant configuration between environments, at the cost of administering multiple directories. Development receives a green visual theme and staging an amber theme, each with an explicit environment label in the sign-in text so that recognition does not depend on color alone. Production uses the regular corporate colors and product name without an environment marker.
Configuration belongs beside the automation
The file layout keeps operational settings and visual assets together:
1setup/
2 ciam-directories.ps1
3 ciam-directories.yml
4 branding/
5 dev/
6 stg/
7 prd/Shared configuration describes the subscription and tenant creation settings. Stage configuration describes names, domains and branding. The following excerpt shows that separation; additional validation, lock and asset settings are omitted:
1tenantId: '00000000-0000-0000-0000-000000000000'
2subscriptionId: '00000000-0000-0000-0000-000000000000'
3resourceProvider: 'Microsoft.AzureActiveDirectory'
4apiVersion: '2023-05-17-preview'
5
6management:
7 resourceGroupLocation: 'germanywestcentral'
8 pollIntervalSeconds: 30
9 operationTimeoutMinutes: 45
10
11ciam:
12 location: 'Europe'
13 countryCode: 'DE'
14 sku:
15 name: 'Standard'
16 tier: 'A0'
17 tags:
18 workload: 'customer-portal-identity'
19 managedBy: 'ciam-directories.ps1'
20
21graph:
22 apiVersion: 'v1.0'
23 scopes:
24 - 'Domain.ReadWrite.All'
25 - 'OrganizationalBranding.ReadWrite.All'
26 useDeviceCode: false
27
28stages:
29 dev:
30 enabled: true
31 resourceGroupName: 'rg-customer-identity-dev'
32 resourceName: 'examplecustomersdev'
33 displayName: 'Example Customer Portal DEV'
34 customDomain:
35 name: 'login-dev.example.com'
36 setAsPrimary: true
37 waitForVerification: false
38 verificationPollIntervalSeconds: 30
39 verificationTimeoutMinutes: 30
40 branding:
41 enabled: true
42 locale: 'en-US'
43 backgroundColor: '#19A974'
44 headerBackgroundColor: '#0B6B4A'
45 signInPageText: 'Example Customer Portal - DEV'
46 usernameHintText: 'Email address'The GUIDs and domain are placeholders. The script deliberately rejects example.com domains and REPLACE- values, so an unedited configuration cannot be provisioned by accident. Actual tenant names must be available, and domain verification requires control of the corresponding DNS zone.
The resource group location and the CIAM data location have different meanings. germanywestcentral locates the resource group metadata; Europe is the external tenant’s configured data geography. Selecting a German resource group does not itself select Germany as the directory’s data location. The ARM schema documents these tenant properties separately in the CIAM directory resource reference
.
Validation happens before provisioning. The setup checks the selected stage, required values, names, colors, URLs and local image files. Asset paths resolve relative to the configuration file, avoiding dependence on the shell’s current working directory.
The configuration is suitable for version control because it contains settings rather than credentials. Tenant and subscription identifiers are not passwords; access tokens, client secrets and authenticated session data do not belong in this file.
Provision the external tenant through Azure Resource Manager
The Azure resource type is Microsoft.AzureActiveDirectory/ciamDirectories. The implementation pins its ARM calls to 2023-05-17-preview, making the API dependency explicit rather than silently changing contracts between runs. The preview designation belongs to this management API version.
The essential creation request has the following shape. Braced values in the URL represent deployment inputs:
1PUT https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.AzureActiveDirectory/ciamDirectories/{resourceName}?api-version=2023-05-17-preview
2Authorization: Bearer {armAccessToken}
3Content-Type: application/json
4
5{
6 "location": "Europe",
7 "sku": {
8 "name": "Standard",
9 "tier": "A0"
10 },
11 "properties": {
12 "createTenantProperties": {
13 "displayName": "Example Customer Portal DEV",
14 "countryCode": "DE"
15 }
16 },
17 "tags": {
18 "workload": "customer-portal-identity",
19 "environment": "dev"
20 }
21}This request uses the documented ARM resource contract . The tenant ID is read from the resulting resource; it is not invented before creation.
The script sends these requests with Invoke-AzRestMethod. That cmdlet reports HTTP failures through the status code of its response instead of throwing an exception. The script therefore checks the status explicitly: a 404 on a read means that the directory does not exist yet, while every other non-success status stops the run. Without that check, a rejected request or an error body could be mistaken for an existing resource.
The script first ensures that the resource group exists, merges its managed tags with existing tags and applies the configured CanNotDelete lock. It then reads the directory resource, creates it if absent and updates managed settings when differences exist. Unrelated tags are preserved.
Tenant creation is asynchronous. An accepted request does not establish that the directory is ready for Graph operations. The implementation waits for the ARM operation and polls the resource with a bounded timeout, stopping on failure and proceeding only after provisioning has completed and a valid tenant ID is available.
On subsequent runs, the script rejects conflicting location and country settings rather than attempting to replace a directory. Microsoft also identifies the country/region choice as unchangeable after creation in its external tenant creation documentation . A directory containing customer identities is not a disposable application instance.
A resource group lock protects against accidental ARM deletion. It does not prevent administrators from changing branding or domain settings through Graph, so tenant permissions remain a separate concern.
Authenticate separately for tenant configuration
The reference implementation uses PowerShell 7 with Az.Accounts, Az.Resources and Microsoft.Graph.Authentication. It runs locally with an Azure login in the workforce tenant, followed by a separate delegated Graph login in the external tenant.
Azure authorization must cover resource creation and the configured management operations, including locks. Microsoft Graph authorization must cover domain and branding changes in the external tenant. An Azure subscription role does not automatically grant those directory permissions.
For delegated Graph access, the setup requests Domain.ReadWrite.All and OrganizationalBranding.ReadWrite.All. The administrator also needs the corresponding supported directory roles. Domain Name Administrator and Organizational Branding Administrator are the least privileged roles documented for the domain verification
and branding update
operations.
Before writes begin, the script checks the active Azure tenant and subscription and displays the selected target. Its -WhatIf mode validates local inputs and the Azure context and suppresses provisioning; it therefore also requires a prior Connect-AzAccount. This is useful for detecting incorrect configuration, but it does not prove that a later Graph login, domain verification or remote update will succeed.
This distinction also defines the automation model: repeatable operations with interactive authentication. Moving the process into an unattended pipeline requires a separate design for workload authentication, permissions in both tenants and DNS access.
Verify the custom domain as a resumable step
Domain ownership introduces a dependency outside Azure and Microsoft Graph. The setup can request verification records, but it cannot publish them without access to the DNS provider.
The implementation therefore treats domain configuration as a sequence that can resume after interruption:
- Read the configured domain from the external tenant and add it if absent.
- Retrieve and display its DNS verification records.
- Attempt verification, either once or repeatedly until the configured timeout.
- Read the domain again to confirm verification.
- Set
isDefaulttotrueand confirm the resulting state.
The corresponding Graph operations are:
1POST /v1.0/domains
2GET /v1.0/domains/{domainName}/verificationDnsRecords
3POST /v1.0/domains/{domainName}/verify
4PATCH /v1.0/domains/{domainName}The creation body contains the domain’s id; the final update contains {"isDefault": true}. Verification is performed by the service using the published DNS evidence, as described in the Graph domain verification API
.
With waitForVerification: false, a missing verification record stops the run after printing the required DNS information. Once the record exists, the same stage can run again. In this implementation, branding follows successful domain configuration, so it has not yet been applied when domain verification stops execution.
Retries should distinguish expected DNS propagation from permission failures, throttling and malformed requests. Treating every verification error as a propagation delay can hide an actionable problem until the timeout expires. The reference script stops immediately on authorization and throttling responses and reports every other failed verification attempt as a warning while it waits.
A default tenant domain is not a custom login URL
Adding login.example.com to the directory and making it the default domain does not configure the browser to authenticate through that hostname.
A custom URL domain requires additional configuration. Microsoft’s documented approach includes associating the domain with the external tenant’s custom URL configuration, setting up Azure Front Door, configuring DNS and TLS, and updating the application’s authentication configuration. These steps are described in the custom URL domain guide .
The setup discussed here manages tenant domain ownership and the default domain. A deployment that also requires a branded authentication hostname must include the additional URL infrastructure and application changes as explicit work.
Apply branding as configuration
Branding has two parts: text and colors represented as properties, and images uploaded as binary streams. The setup manages both and creates the configured language localization when it does not already exist.
For text changes, the implementation compares current values with configured values and sends only the differences. A representative request is:
1PATCH https://graph.microsoft.com/v1.0/organization/{externalTenantId}/branding/localizations/en-US
2Authorization: Bearer {graphAccessToken}
3Content-Type: application/json
4
5{
6 "backgroundColor": "#19A974",
7 "headerBackgroundColor": "#0B6B4A",
8 "signInPageText": "Example Customer Portal - DEV",
9 "usernameHintText": "Email address"
10}The endpoint matters. PATCH /organization/{id}/branding updates the tenant’s default branding object, which Microsoft’s examples address with Accept-Language: 0. A specific browser language is changed through /branding/localizations/{locale}, as described in the branding localization update API
. The script reads, updates and uploads exclusively through the localization path, so text and images always target the same locale. If the localization does not exist yet, the script creates it; according to the localization creation API
, this also creates the default branding object when the tenant has none.
The configuration also supports privacy and terms links. A reliable reconciliation policy needs to distinguish an omitted property from an explicit removal. In the reference implementation, empty optional values are skipped; clearing a value in YAML therefore does not necessarily clear an existing tenant value.
An en-US localization should also not be mistaken for the default branding used by every language. Browsers that request another language fall back to the default branding, which this setup creates implicitly but does not populate. If customers use other browser languages, the default branding or additional localizations need deliberate configuration and verification in the actual sign-in flow.
Upload images independently
The stage-specific asset configuration points to a background, banner logo, header logo, favicon and square logo variants:
1assets:
2 backgroundImage: 'branding/dev/background-image-1920x1080.jpg'
3 bannerLogo: 'branding/dev/banner-logo-245x36.png'
4 headerLogo: 'branding/dev/header-logo-245x36.png'
5 favicon: 'branding/dev/favicon-32x32.png'
6 squareLogo: 'branding/dev/square-logo-240x240.png'
7 squareLogoDark: 'branding/dev/square-logo-dark-240x240.png'These filenames describe the dimensions chosen by the setup. The script inspects the actual images and validates their dimensions, formats and file sizes before starting deployment. The limits follow the company branding documentation , for example 300 KB for the background image, 50 KB for the banner logo and 10 KB for the header logo. A correctly named file is insufficient if its binary content has different dimensions.
Graph handles image uploads separately from JSON property updates. For example, a banner logo is sent as an image stream:
1PUT https://graph.microsoft.com/v1.0/organization/{externalTenantId}/branding/localizations/en-US/bannerLogo
2Authorization: Bearer {graphAccessToken}
3Content-Type: image/png
4
5<binary PNG content>This separation between PATCH for properties and PUT for image streams is defined by the branding localization update API
.
The implementation computes a SHA-256 hash of each local image and compares it with the downloaded tenant asset. Matching content requires no upload. This makes an unchanged rerun quieter and allows individual assets to change independently. The comparison relies on the service returning the stored bytes unchanged; if an image were re-encoded on upload, that asset would simply be uploaded and reported as updated on every run.
An upload response alone does not verify the customer experience. The actual application sign-in flow should be checked for the intended locale, mobile layout, readable text, recognizable logos and footer links that render as intended. Different authentication surfaces can render different parts of the branding configuration.
Run the PowerShell orchestration
The complete implementation is available as ciam-directories.ps1 , with a matching ciam-directories.yml containing all three environments. Both files belong in the same setup directory. The configuration excerpt earlier in this article is intentionally shorter; the downloadable YAML includes the required asset paths, verification timeouts and deletion locks.
Before execution, the configuration needs real workforce tenant and subscription IDs, available tenant resource names, owned custom domains and meaningful ownership tags. The six branding images for each enabled target stage must exist at the configured paths. Corporate image files are not included in the download. This version uses System.Drawing to inspect images and is intended for PowerShell 7 on Windows.
The required modules can be installed in the current user’s PowerShell environment:
1Install-Module -Name Az.Accounts, Az.Resources, Microsoft.Graph.Authentication `
2 -Scope CurrentUser -Repository PSGalleryAuthentication and execution take place in the same PowerShell session. The following commands assume the setup directory is the current directory and that the configuration and branding files have been prepared:
1[string] $workforceTenantId = '00000000-0000-0000-0000-000000000000'
2[string] $subscriptionId = '00000000-0000-0000-0000-000000000000'
3
4Connect-AzAccount -Tenant $workforceTenantId -Subscription $subscriptionId
5
6./ciam-directories.ps1 -Stage dev -WhatIf
7./ciam-directories.ps1 -Stage devThe GUIDs in this command block also require replacement. Normal execution displays the target and requests confirmation before provisioning. The subsequent Graph login authenticates against the created external tenant. With DNS verification waiting disabled, an initial run can stop after displaying the required record; publishing that record and repeating the final command resumes configuration.
The same script selects staging or production explicitly, and -ConfigPath allows a separate configuration file:
1./ciam-directories.ps1 -Stage stg -ConfigPath ./ciam-directories.yml -WhatIf
2./ciam-directories.ps1 -Stage prd -ConfigPath ./ciam-directories.yml -WhatIfThe orchestration sequence
The orchestration below is the final section of the script. It runs after configuration validation, Azure context checks and the ShouldProcess confirmation. Its helper functions and script-scoped settings are defined in the complete implementation below; this excerpt is not a standalone script.
1[string] $stageName = $Stage
2[object] $stageConfiguration = $stageProperty.Value
3[hashtable] $stageTags = Merge-Tags -Base $commonTags -Additional (
4 Get-OptionalValue -Object $stageConfiguration -Name 'tags'
5)
6$stageTags['environment'] = $stageName
7
8[string] $resourceGroupName = Get-RequiredValue `
9 -Object $stageConfiguration -Name 'resourceGroupName' `
10 -Context "stages.$stageName"
11
12[object] $resourceGroup = Ensure-ResourceGroup `
13 -Name $resourceGroupName `
14 -Location $script:ResourceGroupLocation `
15 -Tags $stageTags
16
17[object] $deleteLock = Get-RequiredObject `
18 -Object $stageConfiguration -Name 'deleteLock' `
19 -Context "stages.$stageName"
20
21[hashtable] $lockParameters = @{
22 ResourceGroupName = $resourceGroupName
23 ResourceGroupId = $resourceGroup.ResourceId
24 Enabled = ConvertTo-ConfigBoolean -Value $deleteLock.enabled `
25 -Context "stages.$stageName.deleteLock.enabled"
26 LockName = [string] $deleteLock.name
27 Message = [string] $deleteLock.message
28}
29[void] (Ensure-DeleteLock @lockParameters)
30
31[hashtable] $stageParameters = @{
32 StageConfiguration = $stageConfiguration
33 StageName = $stageName
34}
35[object] $ciamResource = Ensure-CiamDirectory @stageParameters -Tags $stageTags
36[string] $ciamTenantId = Get-PropertyValue `
37 -Object (Get-PropertyValue -Object $ciamResource -Name 'properties') `
38 -Name 'tenantId'
39[guid] $parsedTenantId = [guid]::Empty
40if (-not [guid]::TryParse($ciamTenantId, [ref] $parsedTenantId)) {
41 throw "CIAM resource '$stageName' did not return a valid tenant ID."
42}
43
44[void] (Ensure-CiamCustomDomain @stageParameters -CiamTenantId $ciamTenantId)
45[void] (Ensure-CiamBranding @stageParameters -CiamTenantId $ciamTenantId)Ensure-CiamDirectory handles provisioning and returns the resulting resource. Ensure-CiamCustomDomain establishes the Graph session in that resource’s tenant and completes verification before Ensure-CiamBranding applies text and images. A failure stops the sequence rather than reporting a partially configured environment as complete.
Complete configuration and script
Both blocks are rendered directly from the files offered for download, so the displayed implementation and the downloadable source cannot drift apart. Each block starts as a short preview and can be expanded to the complete file. The YAML parser is included in the script and supports the mappings, lists and scalar values used by this configuration; no additional YAML module is required.
1# Configuration for ciam-directories.ps1 in the same directory.
2#
3# Only values marked [SIGN-IN-VISIBLE] are intended to be visible to a
4# customer during authentication. Their exact rendering depends on the
5# selected sign-in flow and tenant branding. All other settings below are
6# deployment, administration, billing, location, or resource metadata.
7#
8# The workforce tenant configured by tenantId is the tenant from which the
9# script authenticates. The CIAM tenant IDs created by Azure are returned by
10# the script after provisioning; they are not known before the first run.
11
12# Microsoft Entra workforce tenant ID used for authentication and tenant
13# creation permissions. This is not the CIAM tenant ID created for a stage.
14tenantId: '00000000-0000-0000-0000-000000000000'
15
16# Azure subscription that owns the resource groups and CIAM directory
17# resources.
18subscriptionId: '00000000-0000-0000-0000-000000000000'
19
20# Azure resource provider that exposes CIAM directory resources. Do not change
21# this value.
22resourceProvider: 'Microsoft.AzureActiveDirectory'
23
24# ARM API version used for CIAM create/update calls. The script validates this
25# value against the current API version supported by this setup.
26apiVersion: '2023-05-17-preview'
27
28management:
29 # Azure region of each resource group. This is resource-group metadata and
30 # is independent from the CIAM data-residency location.
31 resourceGroupLocation: 'germanywestcentral'
32
33 # Seconds between status checks if Azure still reports a provisioning state
34 # after the REST long-running operation has completed.
35 pollIntervalSeconds: 30
36
37 # Maximum time, in minutes, used by the script while waiting for a CIAM
38 # resource to become readable and reach the Succeeded state.
39 operationTimeoutMinutes: 45
40
41ciam:
42 # Microsoft Entra External ID data-residency location. This is not an Azure
43 # region and is not displayed as customer sign-in text. It determines where
44 # the CIAM tenant data is hosted and is immutable after tenant creation.
45 location: 'Europe'
46
47 # Two-letter country/region selection used when creating the CIAM tenant.
48 # This is tenant metadata, not customer sign-in text, and is immutable after
49 # tenant creation.
50 countryCode: 'DE'
51
52 sku:
53 # CIAM billing/service tier used by the Azure resource. Supported values
54 # are Standard, PremiumP1, and PremiumP2. This is administration/billing
55 # metadata, not customer sign-in text.
56 name: 'Standard'
57
58 # SKU tier required by the CIAM ARM resource API. CIAM currently uses A0
59 # for all supported SKU names. Do not change this value.
60 tier: 'A0'
61
62 tags:
63 # Custom Azure resource tags applied to every CIAM resource and its
64 # resource group. Tags are resource metadata, not customer sign-in text.
65 workload: 'customer-portal-identity'
66
67 # Identifies the tool that manages the Azure resources.
68 managedBy: 'ciam-directories.ps1'
69
70 # Business owner of the Azure resources. Replace this placeholder before
71 # provisioning.
72 owner: 'REPLACE-OWNER'
73
74 # Technical owner of the Azure resources. Replace this placeholder before
75 # provisioning.
76 technical-owner: 'REPLACE-TECHNICAL-OWNER'
77
78 # Source repository for the workload and its automation.
79 repository: 'REPLACE-MY-REPOSITORY'
80
81graph:
82 # Microsoft Graph API version used for custom-domain and branding
83 # management. The v1.0 API is the current stable API; it is not the ARM API
84 # version above.
85 apiVersion: 'v1.0'
86
87 # Delegated Microsoft Graph permissions requested in the CIAM tenant. The
88 # signed-in administrator needs the supported Domain Name Administrator and
89 # Organizational Branding Administrator roles. These permissions are not
90 # visible to customers at sign-in.
91 scopes:
92 - 'Domain.ReadWrite.All'
93 - 'OrganizationalBranding.ReadWrite.All'
94
95 # If true, use device-code authentication for local execution. If false,
96 # use the normal interactive browser authentication flow. This is local
97 # authentication behavior and is not visible to customers.
98 useDeviceCode: false
99
100stages:
101 # Stage identifier used by the mandatory -Stage argument. It is also written
102 # to the environment tag.
103 dev:
104 # If false, this stage cannot be provisioned until it is enabled.
105 enabled: true
106
107 # Azure Resource Group name for this stage.
108 resourceGroupName: 'rg-customer-identity-dev'
109
110 # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
111 # value is used in the CIAM resource name and in the initial domain used by
112 # authentication, typically <resourceName>.onmicrosoft.com. For this stage
113 # the resulting primary domain is examplecustomersdev.onmicrosoft.com.
114 # It must be globally unique, alphanumeric, and no longer than 26 chars.
115 resourceName: 'examplecustomersdev'
116
117 # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
118 # in the customer sign-in/account-selection context and in tenant branding,
119 # depending on the selected sign-in flow.
120 displayName: 'Example Customer Portal DEV'
121
122 customDomain:
123 # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
124 # CIAM tenant's primary/default domain, for example
125 # login-dev.example.com. The domain must be owned by you and must not be
126 # the initial *.onmicrosoft.com domain. Replace this placeholder.
127 name: 'REPLACE-CUSTOM-DOMAIN-DEV'
128
129 # The script always makes this verified custom domain the primary/default
130 # tenant domain. Keep this true for the required CIAM setup.
131 setAsPrimary: true
132
133 # If true, retry domain verification until the DNS record is visible or
134 # the timeout is reached. If false, print the required DNS records and
135 # stop when verification is not yet possible. This affects provisioning,
136 # not the customer sign-in display.
137 waitForVerification: false
138
139 # Seconds between Microsoft Graph verification attempts when waiting is
140 # enabled. DNS propagation can take longer than this interval.
141 verificationPollIntervalSeconds: 30
142
143 # Maximum number of minutes to wait for DNS verification when waiting is
144 # enabled.
145 verificationTimeoutMinutes: 30
146
147 branding:
148 # Enable Microsoft Entra External ID company branding for this stage.
149 # Branding is shown on Microsoft-hosted browser sign-in pages.
150 enabled: true
151
152 # RFC 1766 locale of the managed branding localization. Browsers with
153 # other languages fall back to the tenant's default branding, which this
154 # setup does not populate.
155 locale: 'en-US'
156
157 # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB
158 # format. It is also used when the background image cannot load.
159 backgroundColor: '#19A974'
160
161 # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB format.
162 headerBackgroundColor: '#0B6B4A'
163
164 # [SIGN-IN-VISIBLE] Text shown below the sign-in box. Do not put secrets
165 # or sensitive operational information here.
166 signInPageText: 'Example Customer Portal - DEV'
167
168 # [SIGN-IN-VISIBLE] Hint shown in the username input field.
169 usernameHintText: 'Email address'
170
171 # [SIGN-IN-VISIBLE] Optional footer text and links. Leave a URL empty
172 # when the corresponding custom link should not be configured.
173 customPrivacyAndCookiesText: 'Privacy & Cookies'
174 customPrivacyAndCookiesUrl: ''
175 customTermsOfUseText: 'Terms of Use'
176 customTermsOfUseUrl: ''
177
178 assets:
179 # [SIGN-IN-VISIBLE] Main sign-in background. Required format is
180 # PNG/JPEG, 1920x1080 pixels, and at most 300 KB.
181 backgroundImage: 'branding/dev/background-image-1920x1080.jpg'
182
183 # [SIGN-IN-VISIBLE] Banner logo. PNG/JPEG, 245x36 pixels and at most
184 # 50 KB. The script enforces the exact recommended size.
185 bannerLogo: 'branding/dev/banner-logo-245x36.png'
186
187 # [SIGN-IN-VISIBLE] Header logo. PNG/JPEG, 245x36 pixels and at most
188 # 10 KB. The script enforces the exact recommended size.
189 headerLogo: 'branding/dev/header-logo-245x36.png'
190
191 # [SIGN-IN-VISIBLE] Browser favicon. Required format is PNG/JPEG,
192 # 32x32 pixels, and at most 5 KB.
193 favicon: 'branding/dev/favicon-32x32.png'
194
195 # Square logos used by Microsoft Entra surfaces. Required format is
196 # PNG/JPEG, 240x240 pixels, and at most 50 KB per file.
197 squareLogo: 'branding/dev/square-logo-240x240.png'
198 squareLogoDark: 'branding/dev/square-logo-dark-240x240.png'
199
200 # Resource-group delete protection. All stages are protected by default.
201 # The lock prevents deletion of this resource group and its resources.
202 deleteLock:
203 # Set to false only when this stage must intentionally run without the
204 # configured delete lock. An existing lock is not removed automatically.
205 enabled: true
206
207 # Azure management-lock name. It is visible under Resource group > Locks.
208 name: 'ciam-delete-lock-dev'
209
210 # Configurable note shown with the lock in Azure. It does not affect the
211 # customer sign-in experience.
212 message: 'Do not delete the DEV CIAM resource group or its resources.'
213
214 # Stage-specific resource tags. These are merged with ciam.tags.
215 tags:
216 environment: 'dev'
217
218 # Stage identifier used by the mandatory -Stage argument. It is also written
219 # to the environment tag.
220 stg:
221 # If false, this stage cannot be provisioned until it is enabled.
222 enabled: true
223
224 # Azure Resource Group name for this stage.
225 resourceGroupName: 'rg-customer-identity-stg'
226
227 # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
228 # value must be different from dev and prd and globally unique in
229 # Microsoft Entra. The resulting primary domain is
230 # examplecustomersstg.onmicrosoft.com.
231 resourceName: 'examplecustomersstg'
232
233 # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
234 # in the customer sign-in/account-selection context and in tenant branding.
235 displayName: 'Example Customer Portal STG'
236
237 customDomain:
238 # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
239 # CIAM tenant's primary/default domain. Replace this placeholder.
240 name: 'REPLACE-CUSTOM-DOMAIN-STG'
241 # The custom domain is required to become the primary/default domain.
242 setAsPrimary: true
243 # Retry verification until DNS is visible when true; otherwise print the
244 # DNS records and stop so they can be configured at the registrar.
245 waitForVerification: false
246 # Seconds between verification attempts while waiting.
247 verificationPollIntervalSeconds: 30
248 # Maximum wait time in minutes while waiting for DNS verification.
249 verificationTimeoutMinutes: 30
250
251 branding:
252 # Enable Microsoft Entra External ID company branding for this stage.
253 enabled: true
254 # RFC 1766 locale used for this branding customization.
255 locale: 'en-US'
256 # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB.
257 backgroundColor: '#F2C94C'
258 # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB.
259 headerBackgroundColor: '#B27A00'
260 # [SIGN-IN-VISIBLE] Text shown below the sign-in box.
261 signInPageText: 'Example Customer Portal - STG'
262 # [SIGN-IN-VISIBLE] Hint shown in the username input field.
263 usernameHintText: 'Email address'
264 # [SIGN-IN-VISIBLE] Optional footer text and links.
265 customPrivacyAndCookiesText: 'Privacy & Cookies'
266 customPrivacyAndCookiesUrl: ''
267 customTermsOfUseText: 'Terms of Use'
268 customTermsOfUseUrl: ''
269 assets:
270 # [SIGN-IN-VISIBLE] 1920x1080 PNG/JPEG background, max. 300 KB.
271 backgroundImage: 'branding/stg/background-image-1920x1080.jpg'
272 # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG banner logo, max. 50 KB.
273 bannerLogo: 'branding/stg/banner-logo-245x36.png'
274 # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG header logo, max. 10 KB.
275 headerLogo: 'branding/stg/header-logo-245x36.png'
276 # [SIGN-IN-VISIBLE] 32x32 PNG/JPEG favicon, max. 5 KB.
277 favicon: 'branding/stg/favicon-32x32.png'
278 # [SIGN-IN-VISIBLE] 240x240 PNG/JPEG square logos, max. 50 KB each.
279 squareLogo: 'branding/stg/square-logo-240x240.png'
280 squareLogoDark: 'branding/stg/square-logo-dark-240x240.png'
281
282 # Resource-group delete protection. All stages are protected by default.
283 deleteLock:
284 # Set to false only when this stage must intentionally run without the
285 # configured delete lock. An existing lock is not removed automatically.
286 enabled: true
287
288 # Azure management-lock name. It is visible under Resource group > Locks.
289 name: 'ciam-delete-lock-stg'
290
291 # Configurable note shown with the lock in Azure. It does not affect the
292 # customer sign-in experience.
293 message: 'Do not delete the STG CIAM resource group or its resources.'
294
295 # Stage-specific resource tags. These are merged with ciam.tags.
296 tags:
297 environment: 'stg'
298
299 # Stage identifier used by the mandatory -Stage argument. It is also written
300 # to the environment tag.
301 prd:
302 # If false, this stage cannot be provisioned until it is enabled.
303 enabled: true
304
305 # Azure Resource Group name for this stage.
306 resourceGroupName: 'rg-customer-identity-prd'
307
308 # [SIGN-IN-VISIBLE] CIAM resource name and initial tenant subdomain. This
309 # value must be different from dev and stg and globally unique in
310 # Microsoft Entra. The resulting primary domain is
311 # examplecustomers.onmicrosoft.com.
312 resourceName: 'examplecustomers'
313
314 # [SIGN-IN-VISIBLE] Human-readable CIAM tenant display name. It can appear
315 # in the customer sign-in/account-selection context and in tenant branding.
316 displayName: 'Example Customer Portal'
317
318 customDomain:
319 # [SIGN-IN-VISIBLE] Fully qualified custom domain that must become this
320 # CIAM tenant's primary/default domain. Replace this placeholder.
321 name: 'REPLACE-CUSTOM-DOMAIN-PRD'
322 # The custom domain is required to become the primary/default domain.
323 setAsPrimary: true
324 # Retry verification until DNS is visible when true; otherwise print the
325 # DNS records and stop so they can be configured at the registrar.
326 waitForVerification: false
327 # Seconds between verification attempts while waiting.
328 verificationPollIntervalSeconds: 30
329 # Maximum wait time in minutes while waiting for DNS verification.
330 verificationTimeoutMinutes: 30
331
332 branding:
333 # Enable Microsoft Entra External ID company branding for this stage.
334 enabled: true
335 # RFC 1766 locale used for this branding customization.
336 locale: 'en-US'
337 # [SIGN-IN-VISIBLE] Fallback page background color in hexadecimal RGB.
338 backgroundColor: '#1261A0'
339 # [SIGN-IN-VISIBLE] Header background color in hexadecimal RGB.
340 headerBackgroundColor: '#0B3D91'
341 # [SIGN-IN-VISIBLE] Text shown below the sign-in box. Production uses
342 # the regular product name without an environment marker.
343 signInPageText: 'Example Customer Portal'
344 # [SIGN-IN-VISIBLE] Hint shown in the username input field.
345 usernameHintText: 'Email address'
346 # [SIGN-IN-VISIBLE] Optional footer text and links.
347 customPrivacyAndCookiesText: 'Privacy & Cookies'
348 customPrivacyAndCookiesUrl: ''
349 customTermsOfUseText: 'Terms of Use'
350 customTermsOfUseUrl: ''
351 assets:
352 # [SIGN-IN-VISIBLE] 1920x1080 PNG/JPEG background, max. 300 KB.
353 backgroundImage: 'branding/prd/background-image-1920x1080.jpg'
354 # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG banner logo, max. 50 KB.
355 bannerLogo: 'branding/prd/banner-logo-245x36.png'
356 # [SIGN-IN-VISIBLE] 245x36 PNG/JPEG header logo, max. 10 KB.
357 headerLogo: 'branding/prd/header-logo-245x36.png'
358 # [SIGN-IN-VISIBLE] 32x32 PNG/JPEG favicon, max. 5 KB.
359 favicon: 'branding/prd/favicon-32x32.png'
360 # [SIGN-IN-VISIBLE] 240x240 PNG/JPEG square logos, max. 50 KB each.
361 squareLogo: 'branding/prd/square-logo-240x240.png'
362 squareLogoDark: 'branding/prd/square-logo-dark-240x240.png'
363
364 # Resource-group delete protection. All stages are protected by default.
365 deleteLock:
366 # Set to false only when this stage must intentionally run without the
367 # configured delete lock. An existing lock is not removed automatically.
368 enabled: true
369
370 # Azure management-lock name. It is visible under Resource group > Locks.
371 name: 'ciam-delete-lock-prd'
372
373 # Configurable note shown with the lock in Azure. It does not affect the
374 # customer sign-in experience.
375 message: 'Do not delete the PRD CIAM resource group or its resources.'
376
377 # Stage-specific resource tags. These are merged with ciam.tags.
378 tags:
379 environment: 'prd'
1#requires -Version 7.0
2#requires -Modules Az.Accounts, Az.Resources, Microsoft.Graph.Authentication
3<#
4.SYNOPSIS
5Creates or updates the Microsoft Entra External ID (CIAM) directory for one
6configured stage.
7
8.DESCRIPTION
9Reads all settings from ciam-directories.yml. The script creates or updates
10the configured resource group and the Microsoft.AzureActiveDirectory/
11ciamDirectories resource using the pinned ARM API version configured in YAML.
12
13The same PUT request is used for creation and update. Existing tags that are
14not managed by this file are preserved. Initial CIAM provisioning is
15asynchronous, so the script polls the resource until provisioning has finished.
16Each stage can also manage a resource-group-level CanNotDelete lock with a
17stage-specific name and message. The lock is enabled by default in the YAML.
18
19After the CIAM resource exists, the script uses Microsoft Graph v1.0 in the
20newly created CIAM tenant to add and verify the configured custom domain and to
21set it as the tenant's primary/default domain. DNS ownership records are shown
22when they are not yet available. The script never changes DNS because no DNS
23provider is configured; rerunning the same stage after the records are added is
24idempotent.
25
26The script also manages the configured External ID company branding
27localization for the stage and uploads the configured image streams only when
28their SHA-256 content hash differs from the current tenant asset.
29
30The script is intentionally local-only and uses Azure PowerShell. Authenticate
31first with Az.Accounts:
32
33 Connect-AzAccount -Tenant <workforce-tenant-id> -Subscription <subscription-id>
34
35The custom-domain step then starts a separate delegated Microsoft Graph login
36against the CIAM tenant and requests the configured Graph scopes
37(Domain.ReadWrite.All and OrganizationalBranding.ReadWrite.All).
38
39.EXAMPLE
40pwsh ./ciam-directories.ps1 -Stage dev
41
42.EXAMPLE
43pwsh ./ciam-directories.ps1 -Stage prd -WhatIf
44#>
45[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
46param(
47 [Parameter(Mandatory)]
48 [ValidateSet('dev', 'stg', 'prd')]
49 [string] $Stage,
50
51 [string] $ConfigPath
52)
53
54Set-StrictMode -Version Latest
55$ErrorActionPreference = 'Stop'
56$ProgressPreference = 'SilentlyContinue'
57
58if ([string]::IsNullOrWhiteSpace($ConfigPath)) {
59 $ConfigPath = Join-Path $PSScriptRoot 'ciam-directories.yml'
60}
61
62function Write-Step {
63 param([Parameter(Mandatory)][string] $Message)
64 Write-Host "==> $Message" -ForegroundColor Cyan
65}
66
67function Write-Status {
68 param(
69 [Parameter(Mandatory)][ValidateSet('created', 'updated', 'unchanged', 'skipped')][string] $Status,
70 [string[]] $Details = @()
71 )
72
73 $color = switch ($Status) {
74 'created' { 'Green' }
75 'updated' { 'Yellow' }
76 default { 'DarkGray' }
77 }
78 Write-Host " Status: $Status" -ForegroundColor $color
79 foreach ($detail in @($Details | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })) {
80 Write-Host " - $detail" -ForegroundColor $color
81 }
82}
83
84# The parser is deliberately self-contained so local execution does not need a
85# PowerShell YAML module. It supports only the mapping/list/scalar subset used
86# by the configuration; anchors, multi-line scalars and flow collections are
87# not supported.
88function Remove-YamlComment {
89 param([string] $Value)
90
91 $singleQuoted = $false
92 $doubleQuoted = $false
93 for ($index = 0; $index -lt $Value.Length; $index++) {
94 $character = $Value[$index]
95 if ($singleQuoted) {
96 if ($character -eq "'") { $singleQuoted = $false }
97 continue
98 }
99 if ($doubleQuoted) {
100 if ($character -eq '\') { $index++ }
101 elseif ($character -eq '"') { $doubleQuoted = $false }
102 continue
103 }
104 if ($character -eq "'") { $singleQuoted = $true }
105 elseif ($character -eq '"') { $doubleQuoted = $true }
106 elseif ($character -eq '#' -and ($index -eq 0 -or [char]::IsWhiteSpace($Value[$index - 1]))) {
107 return $Value.Substring(0, $index).TrimEnd()
108 }
109 }
110 return $Value.TrimEnd()
111}
112
113function ConvertFrom-YamlScalar {
114 param([Parameter(Mandatory)][string] $Value)
115
116 $trimmed = $Value.Trim()
117 if ($trimmed -match "^'(.*)'$") { return $Matches[1].Replace("''", "'") }
118 if ($trimmed -match '^".*"$') {
119 try { return $trimmed | ConvertFrom-Json }
120 catch { throw "Invalid quoted YAML scalar '$Value' in '$ConfigPath'." }
121 }
122 if ($trimmed -match '^(true|false)$') { return [bool]::Parse($trimmed) }
123 if ($trimmed -match '^-?\d+$') { return [int64]$trimmed }
124 return $trimmed
125}
126
127function Get-YamlKeyValue {
128 param([Parameter(Mandatory)][string] $Content, [Parameter(Mandatory)][int] $LineNumber)
129
130 if ($Content -notmatch '^(?<key>[A-Za-z][A-Za-z0-9_-]*):(?:\s*(?<value>.*))?$') {
131 throw "Invalid YAML mapping at $ConfigPath line ${LineNumber}: $Content"
132 }
133 return [pscustomobject]@{ Key = $Matches['key']; Value = $Matches['value'] }
134}
135
136function ConvertFrom-YamlNode {
137 param([object[]] $Lines, [ref] $Index, [int] $Indent)
138
139 if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -ne $Indent) {
140 throw "Unexpected YAML indentation in '$ConfigPath'."
141 }
142 if ($Lines[$Index.Value].Content -match '^-' ) {
143 return ConvertFrom-YamlList -Lines $Lines -Index $Index -Indent $Indent
144 }
145 return ConvertFrom-YamlMap -Lines $Lines -Index $Index -Indent $Indent
146}
147
148function ConvertFrom-YamlMap {
149 param([object[]] $Lines, [ref] $Index, [int] $Indent)
150
151 $map = [ordered]@{}
152 while ($Index.Value -lt $Lines.Count) {
153 $line = $Lines[$Index.Value]
154 if ($line.Indent -lt $Indent -or $line.Content -match '^-' ) { break }
155 if ($line.Indent -ne $Indent) { throw "Unexpected YAML indentation at $ConfigPath line $($line.LineNumber)." }
156
157 $entry = Get-YamlKeyValue -Content $line.Content -LineNumber $line.LineNumber
158 $Index.Value++
159 if ([string]::IsNullOrWhiteSpace($entry.Value)) {
160 if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $Indent) {
161 throw "YAML mapping '$($entry.Key)' at $ConfigPath line $($line.LineNumber) requires a nested value."
162 }
163 $map[$entry.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
164 }
165 else {
166 $map[$entry.Key] = ConvertFrom-YamlScalar -Value $entry.Value
167 }
168 }
169 return [pscustomobject] $map
170}
171
172function ConvertFrom-YamlList {
173 param([object[]] $Lines, [ref] $Index, [int] $Indent)
174
175 $items = [System.Collections.ArrayList]::new()
176 while ($Index.Value -lt $Lines.Count) {
177 $line = $Lines[$Index.Value]
178 if ($line.Indent -lt $Indent -or $line.Content -notmatch '^-\s+(?<value>.*)$') { break }
179 if ($line.Indent -ne $Indent) { throw "Unexpected YAML indentation at $ConfigPath line $($line.LineNumber)." }
180
181 $itemContent = $Matches['value']
182 $Index.Value++
183 if ($itemContent -notmatch '^[A-Za-z][A-Za-z0-9_-]*:(?:\s|$)') {
184 [void] $items.Add((ConvertFrom-YamlScalar -Value $itemContent))
185 continue
186 }
187
188 $item = [ordered]@{}
189 $entry = Get-YamlKeyValue -Content $itemContent -LineNumber $line.LineNumber
190 if ([string]::IsNullOrWhiteSpace($entry.Value)) {
191 if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $Indent) {
192 throw "YAML mapping '$($entry.Key)' at $ConfigPath line $($line.LineNumber) requires a nested value."
193 }
194 $item[$entry.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
195 }
196 else {
197 $item[$entry.Key] = ConvertFrom-YamlScalar -Value $entry.Value
198 }
199
200 $propertyIndent = $null
201 while ($Index.Value -lt $Lines.Count -and $Lines[$Index.Value].Indent -gt $Indent) {
202 $propertyLine = $Lines[$Index.Value]
203 if ($null -eq $propertyIndent) { $propertyIndent = $propertyLine.Indent }
204 if ($propertyLine.Indent -ne $propertyIndent -or $propertyLine.Content -match '^-' ) {
205 throw "Nested YAML lists are not supported at $ConfigPath line $($propertyLine.LineNumber)."
206 }
207 $property = Get-YamlKeyValue -Content $propertyLine.Content -LineNumber $propertyLine.LineNumber
208 $Index.Value++
209 if ([string]::IsNullOrWhiteSpace($property.Value)) {
210 if ($Index.Value -ge $Lines.Count -or $Lines[$Index.Value].Indent -le $propertyIndent) {
211 throw "YAML mapping '$($property.Key)' at $ConfigPath line $($propertyLine.LineNumber) requires a nested value."
212 }
213 $item[$property.Key] = ConvertFrom-YamlNode -Lines $Lines -Index $Index -Indent $Lines[$Index.Value].Indent
214 }
215 else {
216 $item[$property.Key] = ConvertFrom-YamlScalar -Value $property.Value
217 }
218 }
219 [void] $items.Add([pscustomobject] $item)
220 }
221 return ,$items.ToArray()
222}
223
224function Read-AzureConfiguration {
225 if (-not (Test-Path -LiteralPath $ConfigPath -PathType Leaf)) {
226 throw "CIAM configuration was not found: $ConfigPath"
227 }
228 $script:ConfigDirectory = Split-Path -Parent (Resolve-Path -LiteralPath $ConfigPath -ErrorAction Stop).Path
229
230 $lines = [System.Collections.ArrayList]::new()
231 $lineNumber = 0
232 foreach ($rawLine in Get-Content -LiteralPath $ConfigPath) {
233 $lineNumber++
234 if ($rawLine -match '\t') { throw "Tabs are not supported in '$ConfigPath' (line $lineNumber)." }
235 $content = Remove-YamlComment -Value $rawLine
236 if ([string]::IsNullOrWhiteSpace($content)) { continue }
237 [void] $lines.Add([pscustomobject]@{
238 Indent = $rawLine.Length - $rawLine.TrimStart().Length
239 Content = $content.Trim()
240 LineNumber = $lineNumber
241 })
242 }
243 if ($lines.Count -eq 0) { throw "Configuration '$ConfigPath' is empty." }
244
245 $index = 0
246 $configuration = ConvertFrom-YamlNode -Lines $lines.ToArray() -Index ([ref] $index) -Indent $lines[0].Indent
247 if ($index -ne $lines.Count) { throw "Unable to parse all YAML configuration in '$ConfigPath'." }
248 return $configuration
249}
250
251function Get-RequiredValue {
252 param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
253
254 $property = $Object.PSObject.Properties[$Name]
255 if ($null -eq $property -or $null -eq $property.Value -or
256 ($property.Value -is [string] -and [string]::IsNullOrWhiteSpace($property.Value))) {
257 throw "Missing required configuration value '$Context.$Name' in '$ConfigPath'."
258 }
259 return $property.Value
260}
261
262function Get-OptionalValue {
263 param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name)
264 $property = $Object.PSObject.Properties[$Name]
265 if ($null -eq $property) { return $null }
266 return $property.Value
267}
268
269function Get-RequiredObject {
270 param([Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
271
272 $value = Get-RequiredValue -Object $Object -Name $Name -Context $Context
273 if ($value -is [string] -or $value -is [ValueType]) {
274 throw "Configuration value '$Context.$Name' in '$ConfigPath' must be an object."
275 }
276 return $value
277}
278
279function ConvertTo-ConfigBoolean {
280 param([Parameter(Mandatory)][object] $Value, [Parameter(Mandatory)][string] $Context)
281
282 if ($Value -is [bool]) { return $Value }
283 switch (([string] $Value).Trim().ToLowerInvariant()) {
284 'true' { return $true }
285 'yes' { return $true }
286 '1' { return $true }
287 'false' { return $false }
288 'no' { return $false }
289 '0' { return $false }
290 default { throw "Configuration value '$Context' in '$ConfigPath' must be true or false." }
291 }
292}
293
294function Get-PropertyValue {
295 param([object] $Object, [Parameter(Mandatory)][string] $Name)
296 if ($null -eq $Object) { return $null }
297 if ($Object -is [System.Collections.IDictionary]) {
298 if (-not $Object.Contains($Name)) { return $null }
299 return $Object[$Name]
300 }
301 $property = $Object.PSObject.Properties[$Name]
302 if ($null -eq $property) { return $null }
303 return $property.Value
304}
305
306function Get-CiamDomainName {
307 param(
308 [Parameter(Mandatory)][object] $Resource,
309 [Parameter(Mandatory)][string] $ResourceName
310 )
311
312 $properties = Get-PropertyValue -Object $Resource -Name 'properties'
313 $domainName = [string] (Get-PropertyValue -Object $properties -Name 'domainName')
314 if ([string]::IsNullOrWhiteSpace($domainName)) {
315 return "$ResourceName.onmicrosoft.com"
316 }
317 return $domainName
318}
319
320function ConvertTo-StringHashtable {
321 param([object] $Object)
322
323 $result = @{}
324 if ($null -eq $Object) { return $result }
325 if ($Object -is [System.Collections.IDictionary]) {
326 foreach ($key in $Object.Keys) {
327 $result[[string] $key] = [string] $Object[$key]
328 }
329 return $result
330 }
331 foreach ($property in $Object.PSObject.Properties) {
332 $result[$property.Name] = [string] $property.Value
333 }
334 return $result
335}
336
337function Merge-Tags {
338 param([hashtable] $Base, [object] $Additional)
339
340 $result = @{}
341 foreach ($entry in $Base.GetEnumerator()) { $result[$entry.Key] = [string] $entry.Value }
342 if ($null -ne $Additional) {
343 foreach ($entry in (ConvertTo-StringHashtable -Object $Additional).GetEnumerator()) {
344 $result[$entry.Key] = [string] $entry.Value
345 }
346 }
347 return $result
348}
349
350function Invoke-AzRestJson {
351 param(
352 [Parameter(Mandatory)][ValidateSet('get', 'put')][string] $Method,
353 [Parameter(Mandatory)][string] $Uri,
354 [object] $Body,
355 [switch] $WaitForCompletion
356 )
357
358 $parameters = @{
359 Uri = $Uri
360 Method = $Method.ToUpperInvariant()
361 ErrorAction = 'Stop'
362 }
363 if ($null -ne $Body) {
364 $parameters.Payload = $Body | ConvertTo-Json -Depth 20 -Compress
365 }
366 if ($WaitForCompletion) {
367 # Az follows Azure-AsyncOperation and returns the final resource. This
368 # avoids guessing how long the initial CIAM provisioning will take.
369 $parameters.WaitForCompletion = $true
370 $parameters.PollFrom = 'AzureAsyncLocation'
371 $parameters.FinalResultFrom = 'OriginalUri'
372 }
373
374 $response = Invoke-AzRestMethod @parameters
375 if ($null -eq $response) { return $null }
376
377 # Invoke-AzRestMethod reports HTTP errors through StatusCode instead of
378 # throwing, so failed ARM requests must be detected explicitly. A missing
379 # resource on GET is an expected state and is returned as $null.
380 $statusCode = [int] $response.StatusCode
381 if ($Method -eq 'get' -and $statusCode -eq 404) { return $null }
382 if ($statusCode -lt 200 -or $statusCode -ge 300) {
383 throw "Azure Resource Manager $($parameters.Method) '$Uri' failed with HTTP ${statusCode}: $($response.Content)"
384 }
385 if ([string]::IsNullOrWhiteSpace([string] $response.Content)) { return $null }
386 return $response.Content | ConvertFrom-Json
387}
388
389function Invoke-GraphJson {
390 param(
391 [Parameter(Mandatory)][ValidateSet('get', 'post', 'patch')][string] $Method,
392 [Parameter(Mandatory)][string] $Uri,
393 [object] $Body
394 )
395
396 $parameters = @{
397 Uri = $Uri
398 Method = $Method.ToUpperInvariant()
399 OutputType = 'PSObject'
400 ErrorAction = 'Stop'
401 }
402 if ($null -ne $Body) {
403 $parameters.Body = $Body | ConvertTo-Json -Depth 20 -Compress
404 $parameters.ContentType = 'application/json'
405 }
406 return Invoke-MgGraphRequest @parameters
407}
408
409function Invoke-GraphBinary {
410 param(
411 [Parameter(Mandatory)][ValidateSet('get', 'put')][string] $Method,
412 [Parameter(Mandatory)][string] $Uri,
413 [byte[]] $Body,
414 [string] $ContentType,
415 [string] $OutputFilePath
416 )
417
418 $parameters = @{
419 Uri = $Uri
420 Method = $Method.ToUpperInvariant()
421 ErrorAction = 'Stop'
422 }
423 if ($null -ne $Body) {
424 $parameters.Body = $Body
425 $parameters.ContentType = $ContentType
426 }
427 if (-not [string]::IsNullOrWhiteSpace($OutputFilePath)) { $parameters.OutputFilePath = $OutputFilePath }
428 return Invoke-MgGraphRequest @parameters
429}
430
431function Connect-CiamGraph {
432 param([Parameter(Mandatory)][string] $TenantId)
433
434 $currentGraphContext = Get-MgContext -ErrorAction SilentlyContinue
435 $currentGraphTenantId = [string] (Get-PropertyValue -Object $currentGraphContext -Name 'TenantId')
436 $currentGraphScopes = @((Get-PropertyValue -Object $currentGraphContext -Name 'Scopes')) | ForEach-Object { [string] $_ }
437 $missingScopes = @($script:GraphScopes | Where-Object { $_ -notin $currentGraphScopes })
438 if ($currentGraphTenantId -ne $TenantId -or $missingScopes.Count -gt 0) {
439 if (-not [string]::IsNullOrWhiteSpace($currentGraphTenantId)) {
440 Disconnect-MgGraph -ErrorAction SilentlyContinue
441 }
442 }
443 else {
444 return
445 }
446
447 Write-Step "Connect Microsoft Graph to CIAM tenant '$TenantId'"
448 $parameters = @{
449 TenantId = $TenantId
450 Scopes = $script:GraphScopes
451 NoWelcome = $true
452 ErrorAction = 'Stop'
453 }
454 if ($script:GraphUseDeviceCode) { $parameters.UseDeviceCode = $true }
455 Connect-MgGraph @parameters | Out-Null
456
457 $context = Get-MgContext -ErrorAction Stop
458 if ([string] $context.TenantId -ne $TenantId) {
459 throw "Microsoft Graph connected to tenant '$($context.TenantId)' instead of CIAM tenant '$TenantId'."
460 }
461}
462
463function Get-GraphDomain {
464 param([Parameter(Mandatory)][string] $DomainName)
465
466 $escapedDomain = [uri]::EscapeDataString($DomainName)
467 $uri = "https://graph.microsoft.com/$script:GraphApiVersion/domains/$escapedDomain"
468 try {
469 return Invoke-GraphJson -Method get -Uri $uri
470 }
471 catch {
472 $errorText = $_ | Out-String
473 if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
474 throw
475 }
476}
477
478function Get-GraphVerificationRecords {
479 param([Parameter(Mandatory)][string] $DomainName)
480
481 $escapedDomain = [uri]::EscapeDataString($DomainName)
482 $uri = "https://graph.microsoft.com/$script:GraphApiVersion/domains/$escapedDomain/verificationDnsRecords"
483 $response = Invoke-GraphJson -Method get -Uri $uri
484 $records = Get-PropertyValue -Object $response -Name 'value'
485 if ($null -eq $records) { return @() }
486 return @($records)
487}
488
489function Get-DnsRecordValue {
490 param([Parameter(Mandatory)][object] $Record)
491
492 foreach ($propertyName in @('text', 'mailExchange', 'canonicalName', 'target', 'value')) {
493 $value = [string] (Get-PropertyValue -Object $Record -Name $propertyName)
494 if (-not [string]::IsNullOrWhiteSpace($value)) { return $value }
495 }
496 return ($Record | ConvertTo-Json -Depth 10 -Compress)
497}
498
499function Write-GraphVerificationRecords {
500 param(
501 [Parameter(Mandatory)][string] $DomainName,
502 [Parameter(Mandatory)][object[]] $Records
503 )
504
505 Write-Host " DNS verification records for '$DomainName':" -ForegroundColor Yellow
506 foreach ($record in $Records) {
507 $recordType = [string] (Get-PropertyValue -Object $record -Name 'recordType')
508 $label = [string] (Get-PropertyValue -Object $record -Name 'label')
509 if ([string]::IsNullOrWhiteSpace($label)) { $label = $DomainName }
510 $ttl = [string] (Get-PropertyValue -Object $record -Name 'ttl')
511 Write-Host " $recordType $label $((Get-DnsRecordValue -Record $record)) (TTL: $ttl)" -ForegroundColor Yellow
512 }
513}
514
515function Ensure-CiamCustomDomain {
516 param(
517 [Parameter(Mandatory)][object] $StageConfiguration,
518 [Parameter(Mandatory)][string] $StageName,
519 [Parameter(Mandatory)][string] $CiamTenantId
520 )
521
522 $customDomain = Get-RequiredObject -Object $StageConfiguration -Name 'customDomain' -Context "stages.$StageName"
523 $domainName = ([string] (Get-RequiredValue -Object $customDomain -Name 'name' -Context "stages.$StageName.customDomain")).ToLowerInvariant()
524 $waitForVerification = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'waitForVerification' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.waitForVerification"
525 $verificationPollIntervalSeconds = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationPollIntervalSeconds' -Context "stages.$StageName.customDomain")
526 $verificationTimeoutMinutes = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationTimeoutMinutes' -Context "stages.$StageName.customDomain")
527
528 Connect-CiamGraph -TenantId $CiamTenantId
529 Write-Step "Ensure custom primary domain '$domainName' for stage '$StageName'"
530 $current = Get-GraphDomain -DomainName $domainName
531 if ($null -eq $current) {
532 $current = Invoke-GraphJson -Method post -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains" -Body ([ordered]@{ id = $domainName })
533 if ($null -eq $current) { $current = Get-GraphDomain -DomainName $domainName }
534 Write-Status -Status created -Details @("Custom domain: $domainName")
535 }
536
537 $isVerified = [bool] (Get-PropertyValue -Object $current -Name 'isVerified')
538 if (-not $isVerified) {
539 $records = @(Get-GraphVerificationRecords -DomainName $domainName)
540 if ($records.Count -gt 0) { Write-GraphVerificationRecords -DomainName $domainName -Records $records }
541
542 $deadline = (Get-Date).AddMinutes($verificationTimeoutMinutes)
543 do {
544 try {
545 [void] (Invoke-GraphJson -Method post -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains/$([uri]::EscapeDataString($domainName))/verify")
546 }
547 catch {
548 # Missing or not yet propagated DNS records are expected here.
549 # Authorization and throttling failures are not DNS problems
550 # and must not be hidden behind the verification timeout.
551 $errorText = $_ | Out-String
552 if ($errorText -match '(?i)(\b401\b|\b403\b|\b429\b|Authorization_RequestDenied|Forbidden|Unauthorized|TooManyRequests)') { throw }
553 Write-Warning "Domain '$domainName' is not verified yet: $($_.Exception.Message)"
554 }
555 $current = Get-GraphDomain -DomainName $domainName
556 $isVerified = [bool] (Get-PropertyValue -Object $current -Name 'isVerified')
557 if ($isVerified) { break }
558
559 if (-not $waitForVerification) {
560 Write-Status -Status skipped -Details @(
561 'Custom domain was added but is not verified.',
562 'Add the displayed DNS verification record and rerun the same stage.'
563 )
564 throw "Custom domain '$domainName' for stage '$StageName' is not verified. Add the required DNS record, then rerun with -Stage $StageName."
565 }
566 if ((Get-Date) -ge $deadline) {
567 throw "Custom domain '$domainName' was not verified before the $verificationTimeoutMinutes minute timeout."
568 }
569 Start-Sleep -Seconds $verificationPollIntervalSeconds
570 } while ($true)
571 }
572
573 $isDefault = [bool] (Get-PropertyValue -Object $current -Name 'isDefault')
574 if (-not $isDefault) {
575 [void] (Invoke-GraphJson -Method patch -Uri "https://graph.microsoft.com/$script:GraphApiVersion/domains/$([uri]::EscapeDataString($domainName))" -Body ([ordered]@{ isDefault = $true }))
576 $current = Get-GraphDomain -DomainName $domainName
577 if (-not [bool] (Get-PropertyValue -Object $current -Name 'isDefault')) {
578 throw "Microsoft Graph did not report custom domain '$domainName' as the default domain after the update."
579 }
580 Write-Status -Status updated -Details @(
581 "Primary/default domain: $domainName",
582 'Visible to users during sign-in where the selected CIAM flow displays the tenant domain.'
583 )
584 }
585 else {
586 Write-Status -Status unchanged -Details @(
587 "Primary/default domain: $domainName",
588 'Visible to users during sign-in where the selected CIAM flow displays the tenant domain.'
589 )
590 }
591 return $current
592}
593
594function Resolve-ConfiguredFilePath {
595 param(
596 [Parameter(Mandatory)][string] $Path,
597 [Parameter(Mandatory)][string] $Context
598 )
599
600 if ([string]::IsNullOrWhiteSpace($Path)) { throw "Configuration value '$Context' must not be empty." }
601 $candidate = if ([System.IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $script:ConfigDirectory $Path }
602 try { return [System.IO.Path]::GetFullPath($candidate) }
603 catch { throw "Configuration value '$Context' is not a valid file path: $Path" }
604}
605
606function Get-GraphBrandingLocalizationUri {
607 param(
608 [Parameter(Mandatory)][string] $TenantId,
609 [Parameter(Mandatory)][string] $Locale
610 )
611 return "https://graph.microsoft.com/$script:GraphApiVersion/organization/$TenantId/branding/localizations/$([uri]::EscapeDataString($Locale))"
612}
613
614function Get-GraphBrandingLocalization {
615 param(
616 [Parameter(Mandatory)][string] $TenantId,
617 [Parameter(Mandatory)][string] $Locale
618 )
619
620 # The localization endpoint is used instead of /branding because PATCH
621 # /branding updates the default branding, not the configured locale.
622 $uri = Get-GraphBrandingLocalizationUri -TenantId $TenantId -Locale $Locale
623 try {
624 return Invoke-GraphJson -Method get -Uri $uri
625 }
626 catch {
627 $errorText = $_ | Out-String
628 if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
629 throw
630 }
631}
632
633function Get-BrandingAssetMetadata {
634 param(
635 [Parameter(Mandatory)][string] $Path,
636 [Parameter(Mandatory)][string] $AssetName,
637 [Parameter(Mandatory)][string] $Context
638 )
639
640 if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
641 throw "Branding asset '$Context' was not found: $Path"
642 }
643 $extension = [System.IO.Path]::GetExtension($Path).ToLowerInvariant()
644 if ($extension -notin @('.png', '.jpg', '.jpeg')) {
645 throw "Branding asset '$Context' must be a PNG or JPEG file."
646 }
647 $fileName = [System.IO.Path]::GetFileName($Path)
648 if ($fileName -notmatch '(?i)-(?<width>\d+)x(?<height>\d+)\.(png|jpg|jpeg)$') {
649 throw "Branding asset '$Context' must include its dimensions in the filename, for example '-1920x1080'."
650 }
651
652 Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue
653 $image = [System.Drawing.Image]::FromFile($Path)
654 try {
655 $width = [int] $Matches['width']
656 $height = [int] $Matches['height']
657 if ($image.Width -ne $width -or $image.Height -ne $height) {
658 throw "Branding asset '$Context' filename says ${width}x${height}, but the actual image is $($image.Width)x$($image.Height)."
659 }
660 return [pscustomobject]@{
661 Path = $Path
662 Extension = $extension
663 Width = $width
664 Height = $height
665 Length = (Get-Item -LiteralPath $Path).Length
666 }
667 }
668 finally { $image.Dispose() }
669}
670
671function Get-BrandingAssetContentType {
672 param([Parameter(Mandatory)][string] $Extension)
673 if ($Extension -eq '.png') { return 'image/png' }
674 return 'image/jpeg'
675}
676
677function Assert-BrandingAsset {
678 param(
679 [Parameter(Mandatory)][string] $Path,
680 [Parameter(Mandatory)][string] $AssetName,
681 [Parameter(Mandatory)][string] $Context
682 )
683
684 $metadata = Get-BrandingAssetMetadata -Path $Path -AssetName $AssetName -Context $Context
685 $requirements = @{
686 backgroundImage = @{ Width = 1920; Height = 1080; MaxBytes = 307200 }
687 bannerLogo = @{ Width = 245; Height = 36; MaxBytes = 51200 }
688 headerLogo = @{ Width = 245; Height = 36; MaxBytes = 10240 }
689 favicon = @{ Width = 32; Height = 32; MaxBytes = 5120 }
690 squareLogo = @{ Width = 240; Height = 240; MaxBytes = 51200 }
691 squareLogoDark = @{ Width = 240; Height = 240; MaxBytes = 51200 }
692 }
693 $requirement = $requirements[$AssetName]
694 if ($metadata.Width -ne $requirement.Width -or $metadata.Height -ne $requirement.Height) {
695 throw "Branding asset '$Context' must be exactly $($requirement.Width)x$($requirement.Height) pixels."
696 }
697 if ($metadata.Length -gt $requirement.MaxBytes) {
698 throw "Branding asset '$Context' is $($metadata.Length) bytes but must not exceed $($requirement.MaxBytes) bytes."
699 }
700 return $metadata
701}
702
703function Get-GraphStreamHash {
704 param([Parameter(Mandatory)][string] $Uri)
705
706 $temporaryFile = [System.IO.Path]::GetTempFileName()
707 try {
708 Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue
709 try {
710 Invoke-GraphBinary -Method get -Uri $Uri -OutputFilePath $temporaryFile | Out-Null
711 }
712 catch {
713 $errorText = $_ | Out-String
714 if ($errorText -match '(?i)(404|Request_ResourceNotFound|ResourceNotFound)') { return $null }
715 throw
716 }
717 if (-not (Test-Path -LiteralPath $temporaryFile -PathType Leaf)) { return $null }
718 if ((Get-Item -LiteralPath $temporaryFile).Length -eq 0) { return $null }
719 return (Get-FileHash -LiteralPath $temporaryFile -Algorithm SHA256).Hash
720 }
721 finally {
722 if (Test-Path -LiteralPath $temporaryFile -PathType Leaf) {
723 Remove-Item -LiteralPath $temporaryFile -Force -ErrorAction SilentlyContinue
724 }
725 }
726}
727
728function Ensure-CiamBranding {
729 param(
730 [Parameter(Mandatory)][object] $StageConfiguration,
731 [Parameter(Mandatory)][string] $StageName,
732 [Parameter(Mandatory)][string] $CiamTenantId
733 )
734
735 $branding = Get-RequiredObject -Object $StageConfiguration -Name 'branding' -Context "stages.$StageName"
736 $enabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $branding -Name 'enabled' -Context "stages.$StageName.branding") -Context "stages.$StageName.branding.enabled"
737 if (-not $enabled) {
738 Write-Status -Status skipped -Details @('External ID branding is disabled for this stage.')
739 return $null
740 }
741
742 $locale = [string] (Get-RequiredValue -Object $branding -Name 'locale' -Context "stages.$StageName.branding")
743 $localizationUri = Get-GraphBrandingLocalizationUri -TenantId $CiamTenantId -Locale $locale
744 $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
745 $changes = [System.Collections.Generic.List[string]]::new()
746 $brandingWasCreated = $false
747 if ($null -eq $brandingResource) {
748 # Creating the first localization also creates the default branding
749 # object when the tenant does not have one yet.
750 $createUri = "https://graph.microsoft.com/$script:GraphApiVersion/organization/$CiamTenantId/branding/localizations"
751 [void] (Invoke-GraphJson -Method post -Uri $createUri -Body ([ordered]@{ id = $locale }))
752 $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
753 $brandingWasCreated = $true
754 [void] $changes.Add("Branding localization created: $locale")
755 }
756
757 $desiredStrings = [ordered]@{
758 backgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'backgroundColor' -Context "stages.$StageName.branding")
759 headerBackgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'headerBackgroundColor' -Context "stages.$StageName.branding")
760 signInPageText = [string] (Get-RequiredValue -Object $branding -Name 'signInPageText' -Context "stages.$StageName.branding")
761 usernameHintText = [string] (Get-RequiredValue -Object $branding -Name 'usernameHintText' -Context "stages.$StageName.branding")
762 }
763 foreach ($optionalProperty in @('customPrivacyAndCookiesText', 'customPrivacyAndCookiesUrl', 'customTermsOfUseText', 'customTermsOfUseUrl')) {
764 $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
765 if ($null -ne $optionalValue -and -not [string]::IsNullOrWhiteSpace([string] $optionalValue)) {
766 $desiredStrings[$optionalProperty] = [string] $optionalValue
767 }
768 }
769
770 $stringChanges = [ordered]@{}
771 foreach ($entry in $desiredStrings.GetEnumerator()) {
772 $currentValue = [string] (Get-PropertyValue -Object $brandingResource -Name $entry.Key)
773 if ($currentValue -cne [string] $entry.Value) {
774 $stringChanges[$entry.Key] = [string] $entry.Value
775 [void] $changes.Add("Branding text setting updated: $($entry.Key)")
776 }
777 }
778 if ($stringChanges.Count -gt 0) {
779 [void] (Invoke-GraphJson -Method patch -Uri $localizationUri -Body $stringChanges)
780 $brandingResource = Get-GraphBrandingLocalization -TenantId $CiamTenantId -Locale $locale
781 }
782
783 $assets = Get-RequiredObject -Object $branding -Name 'assets' -Context "stages.$StageName.branding"
784 $assetNames = @('backgroundImage', 'bannerLogo', 'headerLogo', 'favicon', 'squareLogo', 'squareLogoDark')
785 foreach ($assetName in $assetNames) {
786 $assetRelativePath = [string] (Get-RequiredValue -Object $assets -Name $assetName -Context "stages.$StageName.branding.assets")
787 $assetPath = Resolve-ConfiguredFilePath -Path $assetRelativePath -Context "stages.$StageName.branding.assets.$assetName"
788 $metadata = Assert-BrandingAsset -Path $assetPath -AssetName $assetName -Context "stages.$StageName.branding.assets.$assetName"
789 $assetUri = "$localizationUri/$assetName"
790 $localHash = (Get-FileHash -LiteralPath $assetPath -Algorithm SHA256).Hash
791 $remoteHash = Get-GraphStreamHash -Uri $assetUri
792 if ($localHash -cne $remoteHash) {
793 $bytes = [System.IO.File]::ReadAllBytes($assetPath)
794 [void] (Invoke-GraphBinary -Method put -Uri $assetUri -Body $bytes -ContentType (Get-BrandingAssetContentType -Extension $metadata.Extension))
795 [void] $changes.Add("Branding asset updated: $assetName")
796 }
797 }
798
799 if ($brandingWasCreated) {
800 Write-Status -Status created -Details $changes.ToArray()
801 }
802 elseif ($changes.Count -gt 0) {
803 Write-Status -Status updated -Details $changes.ToArray()
804 }
805 else {
806 Write-Status -Status unchanged -Details @("Branding locale: $locale")
807 }
808 return $brandingResource
809}
810
811function Get-ResourceGroup {
812 param([Parameter(Mandatory)][string] $Name)
813
814 try {
815 return Get-AzResourceGroup -Name $Name -ErrorAction Stop
816 }
817 catch {
818 if ($_.Exception.Message -match '(?i)(ResourceGroupNotFound|was not found|could not be found|404)') { return $null }
819 throw
820 }
821}
822
823function Ensure-ResourceGroup {
824 param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Location, [Parameter(Mandatory)][hashtable] $Tags)
825
826 Write-Step "Ensure resource group '$Name'"
827 $current = Get-ResourceGroup -Name $Name
828
829 if ($null -eq $current) {
830 New-AzResourceGroup -Name $Name -Location $Location -Tag $Tags -ErrorAction Stop | Out-Null
831 Write-Status -Status created -Details @("Location: $Location")
832 return Get-ResourceGroup -Name $Name
833 }
834
835 if ([string] $current.location -cne $Location) {
836 throw "Resource group '$Name' already exists in location '$($current.location)' but YAML requires '$Location'. Resource-group locations are immutable."
837 }
838
839 $changes = [System.Collections.Generic.List[string]]::new()
840 $currentTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
841 foreach ($entry in $Tags.GetEnumerator() | Sort-Object Key) {
842 if (-not $currentTags.ContainsKey($entry.Key)) {
843 [void] $changes.Add("Tag '$($entry.Key)' added: '$($entry.Value)'")
844 }
845 elseif ($currentTags[$entry.Key] -cne [string] $entry.Value) {
846 [void] $changes.Add("Tag '$($entry.Key)': '$($currentTags[$entry.Key])' -> '$($entry.Value)'")
847 }
848 }
849
850 if ($changes.Count -gt 0) {
851 $mergedTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'Tags')
852 foreach ($entry in $Tags.GetEnumerator()) { $mergedTags[$entry.Key] = [string] $entry.Value }
853 Set-AzResourceGroup -Id $current.ResourceId -Tag $mergedTags -ErrorAction Stop | Out-Null
854 Write-Status -Status updated -Details $changes.ToArray()
855 }
856 else {
857 Write-Status -Status unchanged
858 }
859 return Get-ResourceGroup -Name $Name
860}
861
862function Get-DeleteLock {
863 param(
864 [Parameter(Mandatory)][string] $ResourceGroupName,
865 [Parameter(Mandatory)][string] $LockName
866 )
867
868 try {
869 return @(Get-AzResourceLock -ResourceGroupName $ResourceGroupName -LockName $LockName -AtScope -ErrorAction Stop) | Select-Object -First 1
870 }
871 catch {
872 if ($_.Exception.Message -match '(?i)(ResourceLockNotFound|was not found|could not be found|404)') { return $null }
873 throw
874 }
875}
876
877function Ensure-DeleteLock {
878 param(
879 [Parameter(Mandatory)][string] $ResourceGroupName,
880 [Parameter(Mandatory)][string] $ResourceGroupId,
881 [Parameter(Mandatory)][bool] $Enabled,
882 [Parameter(Mandatory)][string] $LockName,
883 [Parameter(Mandatory)][string] $Message
884 )
885
886 Write-Step "Ensure delete lock '$LockName' on resource group '$ResourceGroupName'"
887 $current = Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
888
889 if (-not $Enabled) {
890 if ($null -eq $current) {
891 Write-Status -Status skipped -Details @('Delete lock is disabled and no configured lock exists.')
892 }
893 else {
894 Write-Status -Status skipped -Details @('Delete lock is disabled; the existing lock was intentionally left unchanged.')
895 }
896 return $current
897 }
898
899 if ($null -eq $current) {
900 New-AzResourceLock -Scope $ResourceGroupId -LockName $LockName -LockLevel CanNotDelete -LockNotes $Message -Force -ErrorAction Stop | Out-Null
901 Write-Status -Status created -Details @('Level: CanNotDelete', "Message: $Message")
902 return Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
903 }
904
905 $currentProperties = Get-PropertyValue -Object $current -Name 'Properties'
906 $currentLevel = [string] (Get-PropertyValue -Object $currentProperties -Name 'Level')
907 if ([string]::IsNullOrWhiteSpace($currentLevel)) {
908 $currentLevel = [string] (Get-PropertyValue -Object $current -Name 'LockLevel')
909 }
910 $currentNotes = [string] (Get-PropertyValue -Object $currentProperties -Name 'Notes')
911 if ([string]::IsNullOrWhiteSpace($currentNotes)) {
912 $currentNotes = [string] (Get-PropertyValue -Object $current -Name 'LockNotes')
913 }
914
915 $changes = [System.Collections.Generic.List[string]]::new()
916 if ($currentLevel -cne 'CanNotDelete') {
917 [void] $changes.Add("Level: '$currentLevel' -> 'CanNotDelete'")
918 }
919 if ($currentNotes -cne $Message) {
920 [void] $changes.Add("Message: '$currentNotes' -> '$Message'")
921 }
922
923 if ($changes.Count -eq 0) {
924 Write-Status -Status unchanged -Details @('Level: CanNotDelete', "Message: $Message")
925 return $current
926 }
927
928 Set-AzResourceLock -Scope $ResourceGroupId -LockName $LockName -LockLevel CanNotDelete -LockNotes $Message -Force -ErrorAction Stop | Out-Null
929 Write-Status -Status updated -Details $changes.ToArray()
930 return Get-DeleteLock -ResourceGroupName $ResourceGroupName -LockName $LockName
931}
932
933function Get-CiamDirectory {
934 param([Parameter(Mandatory)][string] $ResourceGroupName, [Parameter(Mandatory)][string] $ResourceName)
935
936 $escapedResourceGroup = [uri]::EscapeDataString($ResourceGroupName)
937 $escapedResourceName = [uri]::EscapeDataString($ResourceName)
938 $uri = "https://management.azure.com/subscriptions/$script:SubscriptionId/resourceGroups/$escapedResourceGroup/providers/$script:ResourceProvider/ciamDirectories/${escapedResourceName}?api-version=$script:ApiVersion"
939 # Invoke-AzRestJson returns $null when the resource does not exist yet.
940 return Invoke-AzRestJson -Method get -Uri $uri
941}
942
943function Wait-CiamDirectory {
944 param(
945 [Parameter(Mandatory)][string] $ResourceGroupName,
946 [Parameter(Mandatory)][string] $ResourceName
947 )
948
949 $deadline = (Get-Date).AddMinutes($script:OperationTimeoutMinutes)
950 $lastState = $null
951 do {
952 $resource = Get-CiamDirectory -ResourceGroupName $ResourceGroupName -ResourceName $ResourceName
953 if ($null -eq $resource) {
954 if ((Get-Date) -ge $deadline) {
955 throw "CIAM resource '$ResourceName' was not readable before the $script:OperationTimeoutMinutes minute timeout."
956 }
957 Write-Verbose "CIAM resource '$ResourceName' is not visible yet; retrying in $script:PollIntervalSeconds seconds."
958 Start-Sleep -Seconds $script:PollIntervalSeconds
959 continue
960 }
961
962 $state = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $resource -Name 'properties') -Name 'provisioningState')
963 if ([string]::IsNullOrWhiteSpace($state)) { return $resource }
964 if ($state -ne $lastState) {
965 Write-Host " Provisioning state: $state" -ForegroundColor DarkCyan
966 $lastState = $state
967 }
968 switch ($state.ToLowerInvariant()) {
969 'succeeded' { return $resource }
970 'failed' { throw "CIAM resource '$ResourceName' provisioning failed. Azure response: $($resource | ConvertTo-Json -Depth 20 -Compress)" }
971 'canceled' { throw "CIAM resource '$ResourceName' provisioning was canceled." }
972 'deleted' { throw "CIAM resource '$ResourceName' was deleted while waiting for provisioning." }
973 }
974
975 if ((Get-Date) -ge $deadline) {
976 throw "CIAM resource '$ResourceName' did not reach Succeeded before the $script:OperationTimeoutMinutes minute timeout. Last state: $state."
977 }
978 Start-Sleep -Seconds $script:PollIntervalSeconds
979 } while ($true)
980}
981
982function Ensure-CiamDirectory {
983 param(
984 [Parameter(Mandatory)][object] $StageConfiguration,
985 [Parameter(Mandatory)][string] $StageName,
986 [Parameter(Mandatory)][hashtable] $Tags
987 )
988
989 $resourceGroupName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceGroupName' -Context "stages.$StageName")
990 $resourceName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceName' -Context "stages.$StageName")
991 $displayName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'displayName' -Context "stages.$StageName")
992
993 $current = Get-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
994 if ($null -ne $current) {
995 $currentState = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $current -Name 'properties') -Name 'provisioningState')
996 if (-not [string]::IsNullOrWhiteSpace($currentState) -and $currentState -ine 'Succeeded') {
997 $current = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
998 }
999 }
1000 $bodyTags = @{}
1001 if ($null -ne $current) {
1002 $bodyTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
1003 }
1004 foreach ($entry in $Tags.GetEnumerator()) { $bodyTags[$entry.Key] = [string] $entry.Value }
1005
1006 $body = [ordered]@{
1007 location = [string] $script:CiamLocation
1008 sku = [ordered]@{
1009 name = [string] $script:SkuName
1010 tier = [string] $script:SkuTier
1011 }
1012 properties = [ordered]@{
1013 createTenantProperties = [ordered]@{
1014 displayName = $displayName
1015 countryCode = [string] $script:CountryCode
1016 }
1017 }
1018 tags = $bodyTags
1019 }
1020
1021 Write-Step "Ensure CIAM directory '$resourceName' in resource group '$resourceGroupName'"
1022 if ($null -eq $current) {
1023 [void] (Invoke-CiamPut -ResourceGroupName $resourceGroupName -ResourceName $resourceName -Body $body)
1024 $result = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
1025 Write-Status -Status created -Details @(
1026 "Display name: $displayName",
1027 "Primary domain: $(Get-CiamDomainName -Resource $result -ResourceName $resourceName)",
1028 "Tenant ID: $((Get-PropertyValue -Object (Get-PropertyValue -Object $result -Name 'properties') -Name 'tenantId'))"
1029 )
1030 return $result
1031 }
1032
1033 $properties = Get-PropertyValue -Object $current -Name 'properties'
1034 $createProperties = Get-PropertyValue -Object $properties -Name 'createTenantProperties'
1035 $currentLocation = [string] (Get-PropertyValue -Object $current -Name 'location')
1036 $currentCountryCode = [string] (Get-PropertyValue -Object $createProperties -Name 'countryCode')
1037 if (-not [string]::IsNullOrWhiteSpace($currentLocation) -and $currentLocation -cne $script:CiamLocation) {
1038 throw "CIAM directory '$resourceName' already uses data location '$currentLocation', but YAML requires '$script:CiamLocation'. CIAM data location is immutable."
1039 }
1040 if (-not [string]::IsNullOrWhiteSpace($currentCountryCode) -and $currentCountryCode -cne $script:CountryCode) {
1041 throw "CIAM directory '$resourceName' already uses country code '$currentCountryCode', but YAML requires '$script:CountryCode'. The country selection is immutable."
1042 }
1043
1044 $changes = [System.Collections.Generic.List[string]]::new()
1045 # createTenantProperties describe the tenant at creation time. Compare the
1046 # display name only when ARM reports it, so a response without these
1047 # properties does not cause an update on every run.
1048 $currentDisplayName = [string] (Get-PropertyValue -Object $createProperties -Name 'displayName')
1049 if (-not [string]::IsNullOrWhiteSpace($currentDisplayName) -and $currentDisplayName -cne $displayName) {
1050 [void] $changes.Add("Display name: '$currentDisplayName' -> '$displayName'")
1051 }
1052 $currentSku = Get-PropertyValue -Object $current -Name 'sku'
1053 if ([string] (Get-PropertyValue -Object $currentSku -Name 'name') -cne $script:SkuName) {
1054 [void] $changes.Add("SKU: '$([string] (Get-PropertyValue -Object $currentSku -Name 'name'))' -> '$script:SkuName'")
1055 }
1056 if ([string] (Get-PropertyValue -Object $currentSku -Name 'tier') -cne $script:SkuTier) {
1057 [void] $changes.Add("SKU tier: '$([string] (Get-PropertyValue -Object $currentSku -Name 'tier'))' -> '$script:SkuTier'")
1058 }
1059 $currentTags = ConvertTo-StringHashtable -Object (Get-PropertyValue -Object $current -Name 'tags')
1060 foreach ($entry in $Tags.GetEnumerator() | Sort-Object Key) {
1061 if (-not $currentTags.ContainsKey($entry.Key)) {
1062 [void] $changes.Add("Tag '$($entry.Key)' added: '$($entry.Value)'")
1063 }
1064 elseif ($currentTags[$entry.Key] -cne [string] $entry.Value) {
1065 [void] $changes.Add("Tag '$($entry.Key)': '$($currentTags[$entry.Key])' -> '$($entry.Value)'")
1066 }
1067 }
1068
1069 if ($changes.Count -eq 0) {
1070 Write-Status -Status unchanged -Details @(
1071 "Primary domain: $(Get-CiamDomainName -Resource $current -ResourceName $resourceName)",
1072 "Tenant ID: $((Get-PropertyValue -Object $properties -Name 'tenantId'))"
1073 )
1074 return $current
1075 }
1076
1077 [void] (Invoke-CiamPut -ResourceGroupName $resourceGroupName -ResourceName $resourceName -Body $body)
1078 $result = Wait-CiamDirectory -ResourceGroupName $resourceGroupName -ResourceName $resourceName
1079 Write-Status -Status updated -Details ($changes.ToArray() + @(
1080 "Primary domain: $(Get-CiamDomainName -Resource $result -ResourceName $resourceName)",
1081 "Tenant ID: $((Get-PropertyValue -Object (Get-PropertyValue -Object $result -Name 'properties') -Name 'tenantId'))"
1082 ))
1083 return $result
1084}
1085
1086function Invoke-CiamPut {
1087 param(
1088 [Parameter(Mandatory)][string] $ResourceGroupName,
1089 [Parameter(Mandatory)][string] $ResourceName,
1090 [Parameter(Mandatory)][object] $Body
1091 )
1092
1093 $escapedResourceGroup = [uri]::EscapeDataString($ResourceGroupName)
1094 $escapedResourceName = [uri]::EscapeDataString($ResourceName)
1095 $uri = "https://management.azure.com/subscriptions/$script:SubscriptionId/resourceGroups/$escapedResourceGroup/providers/$script:ResourceProvider/ciamDirectories/${escapedResourceName}?api-version=$script:ApiVersion"
1096 return Invoke-AzRestJson -Method put -Uri $uri -Body $Body -WaitForCompletion
1097}
1098
1099function Assert-ResourceGroupName {
1100 param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
1101 if ($Name -notmatch '^[A-Za-z0-9._()\-]{1,90}$') {
1102 throw "Configuration value '$Context.resourceGroupName' must be a valid Azure resource-group name."
1103 }
1104}
1105
1106function Assert-CustomDomainName {
1107 param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Context)
1108
1109 if ($Name -match '(?i)(REPLACE|example\.com|\.onmicrosoft\.com$)') {
1110 throw "Configuration value '$Context' must be replaced with a real custom domain and must not be the initial onmicrosoft.com domain."
1111 }
1112 if ($Name.Length -gt 253 -or $Name -notmatch '^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$') {
1113 throw "Configuration value '$Context' must be a fully qualified custom domain such as login-dev.contoso.com."
1114 }
1115}
1116
1117function Assert-ConfiguredStage {
1118 param([Parameter(Mandatory)][object] $StageConfiguration, [Parameter(Mandatory)][string] $StageName)
1119
1120 $enabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $StageConfiguration -Name 'enabled' -Context "stages.$StageName") -Context "stages.$StageName.enabled"
1121 if (-not $enabled) { throw "Stage '$StageName' is disabled in '$ConfigPath'." }
1122
1123 $resourceGroupName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceGroupName' -Context "stages.$StageName")
1124 $resourceName = [string] (Get-RequiredValue -Object $StageConfiguration -Name 'resourceName' -Context "stages.$StageName")
1125 [void] (Get-RequiredValue -Object $StageConfiguration -Name 'displayName' -Context "stages.$StageName")
1126 $customDomain = Get-RequiredObject -Object $StageConfiguration -Name 'customDomain' -Context "stages.$StageName"
1127 $customDomainName = [string] (Get-RequiredValue -Object $customDomain -Name 'name' -Context "stages.$StageName.customDomain")
1128 $setAsPrimary = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'setAsPrimary' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.setAsPrimary"
1129 if (-not $setAsPrimary) { throw "Configuration value 'stages.$StageName.customDomain.setAsPrimary' must be true. Every stage must use its custom domain as the primary/default domain." }
1130 [void] (ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $customDomain -Name 'waitForVerification' -Context "stages.$StageName.customDomain") -Context "stages.$StageName.customDomain.waitForVerification")
1131 $verificationPollIntervalSeconds = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationPollIntervalSeconds' -Context "stages.$StageName.customDomain")
1132 $verificationTimeoutMinutes = [int] (Get-RequiredValue -Object $customDomain -Name 'verificationTimeoutMinutes' -Context "stages.$StageName.customDomain")
1133 $deleteLock =Get-RequiredObject -Object $StageConfiguration -Name 'deleteLock' -Context "stages.$StageName"
1134 [void] (ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $deleteLock -Name 'enabled' -Context "stages.$StageName.deleteLock") -Context "stages.$StageName.deleteLock.enabled")
1135 $lockName = [string] (Get-RequiredValue -Object $deleteLock -Name 'name' -Context "stages.$StageName.deleteLock")
1136 $lockMessage = [string] (Get-RequiredValue -Object $deleteLock -Name 'message' -Context "stages.$StageName.deleteLock")
1137 Assert-ResourceGroupName -Name $resourceGroupName -Context "stages.$StageName"
1138 if ($resourceName -notmatch '^[A-Za-z0-9]{1,26}$') {
1139 throw "Configuration value 'stages.$StageName.resourceName' must contain only letters and numbers and be at most 26 characters."
1140 }
1141 Assert-CustomDomainName -Name $customDomainName -Context "stages.$StageName.customDomain.name"
1142 if ($verificationPollIntervalSeconds -lt 5 -or $verificationPollIntervalSeconds -gt 300) {
1143 throw "Configuration value 'stages.$StageName.customDomain.verificationPollIntervalSeconds' must be between 5 and 300."
1144 }
1145 if ($verificationTimeoutMinutes -lt 1 -or $verificationTimeoutMinutes -gt 180) {
1146 throw "Configuration value 'stages.$StageName.customDomain.verificationTimeoutMinutes' must be between 1 and 180."
1147 }
1148
1149 $branding = Get-RequiredObject -Object $StageConfiguration -Name 'branding' -Context "stages.$StageName"
1150 $brandingEnabled = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $branding -Name 'enabled' -Context "stages.$StageName.branding") -Context "stages.$StageName.branding.enabled"
1151 if ($brandingEnabled) {
1152 $locale = [string] (Get-RequiredValue -Object $branding -Name 'locale' -Context "stages.$StageName.branding")
1153 $backgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'backgroundColor' -Context "stages.$StageName.branding")
1154 $headerBackgroundColor = [string] (Get-RequiredValue -Object $branding -Name 'headerBackgroundColor' -Context "stages.$StageName.branding")
1155 $signInPageText = [string] (Get-RequiredValue -Object $branding -Name 'signInPageText' -Context "stages.$StageName.branding")
1156 $usernameHintText = [string] (Get-RequiredValue -Object $branding -Name 'usernameHintText' -Context "stages.$StageName.branding")
1157 if ($locale -notmatch '^[a-z]{2}-[A-Z]{2}$') { throw "Configuration value 'stages.$StageName.branding.locale' must be an RFC 1766 locale such as en-US." }
1158 if ($backgroundColor -notmatch '^#[0-9A-Fa-f]{6}$' -or $headerBackgroundColor -notmatch '^#[0-9A-Fa-f]{6}$') {
1159 throw "Configuration values 'stages.$StageName.branding.backgroundColor' and 'headerBackgroundColor' must be hexadecimal RGB colors such as #1261A0."
1160 }
1161 if ($signInPageText.Length -gt 1024) { throw "Configuration value 'stages.$StageName.branding.signInPageText' must not exceed 1024 characters." }
1162 if ($usernameHintText.Length -gt 64) { throw "Configuration value 'stages.$StageName.branding.usernameHintText' must not exceed 64 characters." }
1163 foreach ($optionalProperty in @('customPrivacyAndCookiesText', 'customTermsOfUseText')) {
1164 $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
1165 if ($null -ne $optionalValue -and ([string] $optionalValue).Length -gt 256) {
1166 throw "Configuration value 'stages.$StageName.branding.$optionalProperty' must not exceed 256 characters."
1167 }
1168 }
1169 foreach ($optionalProperty in @('customPrivacyAndCookiesUrl', 'customTermsOfUseUrl')) {
1170 $optionalValue = Get-OptionalValue -Object $branding -Name $optionalProperty
1171 if ($null -ne $optionalValue -and -not [string]::IsNullOrWhiteSpace([string] $optionalValue)) {
1172 if (([string] $optionalValue).Length -gt 128 -or ([string] $optionalValue) -notmatch '^https?://') {
1173 throw "Configuration value 'stages.$StageName.branding.$optionalProperty' must be an HTTP(S) URL with at most 128 characters."
1174 }
1175 }
1176 }
1177
1178 $assets = Get-RequiredObject -Object $branding -Name 'assets' -Context "stages.$StageName.branding"
1179 foreach ($assetName in @('backgroundImage', 'bannerLogo', 'headerLogo', 'favicon', 'squareLogo', 'squareLogoDark')) {
1180 $assetRelativePath = [string] (Get-RequiredValue -Object $assets -Name $assetName -Context "stages.$StageName.branding.assets")
1181 $assetPath = Resolve-ConfiguredFilePath -Path $assetRelativePath -Context "stages.$StageName.branding.assets.$assetName"
1182 [void] (Assert-BrandingAsset -Path $assetPath -AssetName $assetName -Context "stages.$StageName.branding.assets.$assetName")
1183 }
1184 }
1185 if ($lockName -notmatch '^[A-Za-z0-9._-]{1,64}$') {
1186 throw "Configuration value 'stages.$StageName.deleteLock.name' must contain only letters, numbers, periods, underscores, or hyphens and be at most 64 characters."
1187 }
1188 if ($lockMessage -match '[\r\n]' -or [string]::IsNullOrWhiteSpace($lockMessage)) {
1189 throw "Configuration value 'stages.$StageName.deleteLock.message' must not be empty or contain line breaks."
1190 }
1191}
1192
1193$configuration = Read-AzureConfiguration
1194$script:TenantId = [string] (Get-RequiredValue -Object $configuration -Name 'tenantId' -Context 'root')
1195$script:SubscriptionId = [string] (Get-RequiredValue -Object $configuration -Name 'subscriptionId' -Context 'root')
1196$script:ResourceProvider = [string] (Get-RequiredValue -Object $configuration -Name 'resourceProvider' -Context 'root')
1197$script:ApiVersion = [string] (Get-RequiredValue -Object $configuration -Name 'apiVersion' -Context 'root')
1198
1199if ($script:TenantId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "Configuration value 'tenantId' must be a tenant GUID." }
1200if ($script:SubscriptionId -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw "Configuration value 'subscriptionId' must be a subscription GUID." }
1201if ($script:ResourceProvider -cne 'Microsoft.AzureActiveDirectory') { throw "Configuration value 'resourceProvider' must be 'Microsoft.AzureActiveDirectory'." }
1202if ($script:ApiVersion -cne '2023-05-17-preview') { throw "Configuration value 'apiVersion' must be the pinned CIAM API version '2023-05-17-preview'." }
1203
1204$management = Get-RequiredObject -Object $configuration -Name 'management' -Context 'root'
1205$script:ResourceGroupLocation = [string] (Get-RequiredValue -Object $management -Name 'resourceGroupLocation' -Context 'management')
1206$script:PollIntervalSeconds = [int] (Get-RequiredValue -Object $management -Name 'pollIntervalSeconds' -Context 'management')
1207$script:OperationTimeoutMinutes = [int] (Get-RequiredValue -Object $management -Name 'operationTimeoutMinutes' -Context 'management')
1208if ($script:PollIntervalSeconds -lt 5 -or $script:PollIntervalSeconds -gt 60) { throw "Configuration value 'management.pollIntervalSeconds' must be between 5 and 60." }
1209if ($script:OperationTimeoutMinutes -lt 1 -or $script:OperationTimeoutMinutes -gt 180) { throw "Configuration value 'management.operationTimeoutMinutes' must be between 1 and 180." }
1210
1211$graph = Get-RequiredObject -Object $configuration -Name 'graph' -Context 'root'
1212$script:GraphApiVersion = [string] (Get-RequiredValue -Object $graph -Name 'apiVersion' -Context 'graph')
1213$configuredGraphScopes = Get-RequiredValue -Object $graph -Name 'scopes' -Context 'graph'
1214$script:GraphScopes = @($configuredGraphScopes | ForEach-Object { [string] $_ })
1215$script:GraphUseDeviceCode = ConvertTo-ConfigBoolean -Value (Get-RequiredValue -Object $graph -Name 'useDeviceCode' -Context 'graph') -Context 'graph.useDeviceCode'
1216if ($script:GraphApiVersion -cne 'v1.0') { throw "Configuration value 'graph.apiVersion' must be 'v1.0' for the current stable Microsoft Graph domain API." }
1217foreach ($requiredGraphScope in @('Domain.ReadWrite.All', 'OrganizationalBranding.ReadWrite.All')) {
1218 if ($requiredGraphScope -notin $script:GraphScopes) { throw "Configuration value 'graph.scopes' must include '$requiredGraphScope'." }
1219}
1220
1221$ciam = Get-RequiredObject -Object $configuration -Name 'ciam' -Context 'root'
1222$script:CiamLocation = [string] (Get-RequiredValue -Object $ciam -Name 'location' -Context 'ciam')
1223$script:CountryCode = ([string] (Get-RequiredValue -Object $ciam -Name 'countryCode' -Context 'ciam')).ToUpperInvariant()
1224$sku = Get-RequiredObject -Object $ciam -Name 'sku' -Context 'ciam'
1225$script:SkuName = [string] (Get-RequiredValue -Object $sku -Name 'name' -Context 'ciam.sku')
1226$script:SkuTier = [string] (Get-RequiredValue -Object $sku -Name 'tier' -Context 'ciam.sku')
1227$commonTags = ConvertTo-StringHashtable -Object (Get-RequiredObject -Object $ciam -Name 'tags' -Context 'ciam')
1228
1229if ($script:CiamLocation -notin @('United States', 'Europe', 'Asia Pacific', 'Australia')) { throw "Configuration value 'ciam.location' must be United States, Europe, Asia Pacific, or Australia." }
1230if ($script:CountryCode -notmatch '^[A-Z]{2}$') { throw "Configuration value 'ciam.countryCode' must be a two-letter country code." }
1231if ($script:SkuName -notin @('Standard', 'PremiumP1', 'PremiumP2')) { throw "Configuration value 'ciam.sku.name' must be Standard, PremiumP1, or PremiumP2." }
1232if ($script:SkuTier -cne 'A0') { throw "Configuration value 'ciam.sku.tier' must be A0 for CIAM." }
1233
1234$stages = Get-RequiredObject -Object $configuration -Name 'stages' -Context 'root'
1235$stageProperty = $stages.PSObject.Properties[$Stage]
1236if ($null -eq $stageProperty) { throw "Stage '$Stage' was not found in '$ConfigPath'. Configured stages: $($stages.PSObject.Properties.Name -join ', ')." }
1237Assert-ConfiguredStage -StageConfiguration $stageProperty.Value -StageName $Stage
1238
1239$context = Get-AzContext
1240if ($null -eq $context) {
1241 throw "No Azure PowerShell context is available. Run 'Connect-AzAccount -Tenant $script:TenantId -Subscription $script:SubscriptionId' first."
1242}
1243if ([string] $context.Subscription.Id -ne $script:SubscriptionId) {
1244 Set-AzContext -SubscriptionId $script:SubscriptionId -ErrorAction Stop | Out-Null
1245 $context = Get-AzContext
1246}
1247$activeTenantId = [string] (Get-PropertyValue -Object (Get-PropertyValue -Object $context -Name 'Tenant') -Name 'Id')
1248if ([string]::IsNullOrWhiteSpace($activeTenantId) -or $activeTenantId -ne $script:TenantId) {
1249 throw "The active Azure PowerShell tenant '$activeTenantId' does not match configured tenant '$script:TenantId'. Run 'Connect-AzAccount -Tenant $script:TenantId' and try again."
1250}
1251
1252Write-Host ''
1253Write-Host 'Review CIAM target' -ForegroundColor Yellow
1254Write-Host " Tenant: $script:TenantId"
1255Write-Host " Subscription: $($context.Subscription.Name) ($($context.Subscription.Id))"
1256Write-Host " API version: $script:ApiVersion"
1257Write-Host " Graph API: $script:GraphApiVersion ($($script:GraphScopes -join ', '))"
1258Write-Host " Data location: $script:CiamLocation"
1259Write-Host " Country code: $script:CountryCode"
1260Write-Host " Stage: $Stage"
1261Write-Host ''
1262
1263if (-not $PSCmdlet.ShouldProcess("CIAM directory for stage $Stage", 'Create or update')) {
1264 Write-Host 'Configuration was suppressed. No Azure resources were changed.' -ForegroundColor Yellow
1265 return
1266}
1267
1268[string] $stageName = $Stage
1269[object] $stageConfiguration = $stageProperty.Value
1270[hashtable] $stageTags = Merge-Tags -Base $commonTags -Additional (
1271 Get-OptionalValue -Object $stageConfiguration -Name 'tags'
1272)
1273$stageTags['environment'] = $stageName
1274
1275[string] $resourceGroupName = Get-RequiredValue `
1276 -Object $stageConfiguration -Name 'resourceGroupName' `
1277 -Context "stages.$stageName"
1278
1279[object] $resourceGroup = Ensure-ResourceGroup `
1280 -Name $resourceGroupName `
1281 -Location $script:ResourceGroupLocation `
1282 -Tags $stageTags
1283
1284[object] $deleteLock = Get-RequiredObject `
1285 -Object $stageConfiguration -Name 'deleteLock' `
1286 -Context "stages.$stageName"
1287
1288[hashtable] $lockParameters = @{
1289 ResourceGroupName = $resourceGroupName
1290 ResourceGroupId = $resourceGroup.ResourceId
1291 Enabled = ConvertTo-ConfigBoolean -Value $deleteLock.enabled `
1292 -Context "stages.$stageName.deleteLock.enabled"
1293 LockName = [string] $deleteLock.name
1294 Message = [string] $deleteLock.message
1295}
1296[void] (Ensure-DeleteLock @lockParameters)
1297
1298[hashtable] $stageParameters = @{
1299 StageConfiguration = $stageConfiguration
1300 StageName = $stageName
1301}
1302[object] $ciamResource = Ensure-CiamDirectory @stageParameters -Tags $stageTags
1303[string] $ciamTenantId = Get-PropertyValue `
1304 -Object (Get-PropertyValue -Object $ciamResource -Name 'properties') `
1305 -Name 'tenantId'
1306[guid] $parsedTenantId = [guid]::Empty
1307if (-not [guid]::TryParse($ciamTenantId, [ref] $parsedTenantId)) {
1308 throw "CIAM resource '$stageName' did not return a valid tenant ID."
1309}
1310
1311[void] (Ensure-CiamCustomDomain @stageParameters -CiamTenantId $ciamTenantId)
1312[void] (Ensure-CiamBranding @stageParameters -CiamTenantId $ciamTenantId)
1313
1314Write-Host ''
1315Write-Host 'CIAM provisioning completed.' -ForegroundColor Green
Operate the setup as a sequence of recoverable changes
There is no transaction spanning resource group creation, tenant provisioning, DNS verification and Graph branding. A failed run can leave a successfully provisioned tenant with unfinished domain or branding configuration.
The appropriate recovery is usually to resolve the failing prerequisite and rerun the same environment. Deleting the tenant to start over risks destroying identity state and changes identifiers on which applications depend.
The setup’s created, updated, unchanged and skipped messages make these outcomes easier to distinguish. A useful operational review checks that:
- ARM reports successful provisioning and the expected external tenant ID.
- Graph authentication targets that external tenant.
- The custom domain is verified and has the intended default status.
- Branding properties and image content match the selected environment.
- A second run with unchanged configuration avoids unnecessary writes.
- A real sign-in flow displays the intended branding after application configuration is complete.
For broader automation, transient service failures need bounded retries, throttling responses need their prescribed delay, and logs need enough context to diagnose the failing operation without recording access tokens.
Identity configuration deserves the same discipline as application infrastructure
CIAM centralizes a demanding part of application security, but the identity environment still needs deliberate configuration and operation. Tenant separation, explicit API versions, versioned branding assets and resumable domain verification make that environment easier to review and maintain.
Azure Resource Manager provides the directory foundation. Microsoft Graph configures the customer-facing tenant. Keeping their authentication contexts and responsibilities explicit produces a repeatable setup with clear recovery points, while leaving application authorization and complete sign-in behavior visible as separate responsibilities.
Related articles

- 11 min read
Top 10 recommendations for .NET developers working with Azure Cosmos DB
Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with …

- 16 min read
Production Rate Limiting in ASP.NET Core
Most ASP.NET Core rate-limiting examples stop after registering a fixed window. That is enough to demonstrate the middleware, but it is not …

- 10 min read
Distroless, Chiseled or Alpine: Choosing a .NET Base Image
Distroless, Chiseled and Alpine are often compared as three equivalent Linux distributions. They are not. Alpine is a distribution built …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
