From 577103b7b264c3aaf50fbc7aa8f995f0f12d014c Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 30 Jan 2026 11:46:28 -0800 Subject: [PATCH 1/9] script draft --- .azure/scripts/New-EeidAppRegistrations.ps1 | 156 ++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .azure/scripts/New-EeidAppRegistrations.ps1 diff --git a/.azure/scripts/New-EeidAppRegistrations.ps1 b/.azure/scripts/New-EeidAppRegistrations.ps1 new file mode 100644 index 0000000..83b6c25 --- /dev/null +++ b/.azure/scripts/New-EeidAppRegistrations.ps1 @@ -0,0 +1,156 @@ +# EEID App Registration and Secret Setup Script +# Location: .azure/scripts/New-EeidAppRegistrations.ps1 +# Description: Idempotently installs prerequisites, creates/updates EEID app registrations, sets permissions, and configures dotnet user-secrets for Presentation.WebApi and Presentation.Blazor. +param( + [string]$EeIdInstanceUrl, + [string]$TenantId, + [string]$SubscriptionId, + [string]$WebAppRegistrationName = "web-semker-dev", + [string]$WebProjectPath = "../../src/Presentation.Blazor", + [string]$ApiAppRegistrationName = "api-semker-dev", + [string]$ApiProjectPath = "../../src/Presentation.WebApi", + [string]$DotNetVersion = "10" +) + +# Step 1: Install prerequisites (az cli, dotnet sdk, modules) +Write-Host "Checking prerequisites..." + +# Check and install Azure CLI +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Host "Azure CLI not found. Installing via winget..." + winget install --id Microsoft.AzureCLI -e --silent +} else { + Write-Host "Azure CLI is already installed." +} + +# Check and install .NET SDK +$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." +if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent +} else { + Write-Host ".NET SDK $DotNetVersion is already installed." +} + +# Check and install PowerShell modules +$modules = @("Az.Accounts", "Az.Resources") +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } else { + Write-Host "PowerShell module $module is already installed." + } +} + +# Step 2: Login to Azure and set EEID tenant/subscription +Write-Host "Logging into Azure..." +$azLoggedIn = az account show 2>$null +if (-not $azLoggedIn) { + az login --tenant $TenantId + Write-Host "Logged in to Azure tenant $TenantId." +} else { + Write-Host "Already logged in to Azure." +} + +Write-Host "Setting Azure subscription..." +$currentSub = az account show --query id -o tsv +if ($currentSub -ne $SubscriptionId) { + az account set --subscription $SubscriptionId + Write-Host "Azure subscription set to $SubscriptionId." +} else { + Write-Host "Azure subscription already set to $SubscriptionId." +} + +# Step 3: Check for API app registration by name; create if missing +Write-Host "Checking for API app registration: $ApiAppRegistrationName..." +$apiApp = az ad app list --display-name $ApiAppRegistrationName --query "[0]" -o json | ConvertFrom-Json +if (-not $apiApp) { + Write-Host "API app registration not found. Creating..." + $apiApp = az ad app create ` + --display-name $ApiAppRegistrationName ` + --sign-in-audience AzureADMyOrg ` + --identifier-uris "api://$ApiAppRegistrationName" ` + --required-resource-access '[{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' ` + --api-access-token-version 2 ` + --query "appId" -o tsv + $apiAppId = $apiApp + Write-Host "Created API app registration with appId: $apiAppId" + # Add permission scopes + $scopes = @( + @{name="digitalinsights.admin"; adminConsentDisplayName="Admin digital insights"; adminConsentDescription="Allows the app to administrate digital insights features, templates, connectors for a signed-in user's digital insights"; userConsentDisplayName="Admin your digital insights"; userConsentDescription="Allows the app to administrate digital insights features, templates, connectors for a digital insights"}, + @{name="activity.execute"; adminConsentDisplayName="Execute feature activity triggers"; adminConsentDescription="Allows the app to execute a feature activity for digital assets"; userConsentDisplayName="Execute a feature"; userConsentDescription="Allows the app to execute a feature activity for your digital assets"}, + @{name="features.read"; adminConsentDisplayName="View feature configuration and results"; adminConsentDescription="Allows the app to view the signed-in user's feature configuration and results"; userConsentDisplayName="Read your features"; userConsentDescription="Allows the app to read your enrolled features"}, + @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to read asset profiles and metadata"; userConsentDisplayName="Read your Assets"; userConsentDescription="Allows the app to read your asset profiles and metadata"}, + @{name="features.enroll"; adminConsentDisplayName="Enrolls features and write their setups"; adminConsentDescription="Allows enrollment of features, data sources and configurations."; userConsentDisplayName="Enroll any feature"; userConsentDescription="Allows app to eny any features"}, + @{name="analytics.read"; adminConsentDisplayName="Read analytics"; adminConsentDescription="Allows the app to read analytics for your digital assets"; userConsentDisplayName="Read your digital assets analytics"; userConsentDescription="Allows the app to read analytics for your digital assets"}, + @{name="assets.write"; adminConsentDisplayName="Read/Write assets"; adminConsentDescription="Allows the app to read and write to the signed in user's digital assets."; userConsentDisplayName="Read/Write your digital assets"; userConsentDescription="Allows the app to read/write your digital assets."} + ) + foreach ($scope in $scopes) { + az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription + } + # Add app roles + $roles = @( + @{value="AnalyticsReader"; displayName="Analytics Readers"; description="Analytic Readers can view analytics from discovery"}, + @{value="DiscoveryExecuter"; displayName="Discovery Executers"; description="Discover Executer start a discovery process"}, + @{value="FeatureManager"; displayName="Feature Managers"; description="Feature Manager can alter definitions for features and data sources."} + ) + foreach ($role in $roles) { + az ad app update --id $apiAppId --app-roles "[{\"allowedMemberTypes\":[\"User\"],\"description\":\"$($role.description)\",\"displayName\":\"$($role.displayName)\",\"isEnabled\":true,\"origin\":\"Application\",\"value\":\"$($role.value)\"}]" + } +} else { + Write-Host "API app registration $ApiAppRegistrationName already exists." +} + +# Step 4: Write API EEID values to Presentation.WebApi via dotnet user-secrets +Write-Host "Setting EntraExternalId values for $ApiProjectPath" +Push-Location $ApiProjectPath +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl +dotnet user-secrets set "EntraExternalId:TenantId" $TenantId +dotnet user-secrets set "EntraExternalId:ClientId" $apiApp.appId +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +Pop-Location + +# Step 5: Check for Web app registration by name; create if missing +Write-Host "Checking for Web app registration: $WebAppRegistrationName..." +$webApp = az ad app list --display-name $WebAppRegistrationName --query "[0]" -o json | ConvertFrom-Json +if (-not $webApp) { + Write-Host "Web app registration not found. Creating..." + $webApp = az ad app create --display-name $WebAppRegistrationName \ + --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "https://localhost:7175/signin-oidc" \ + --web-logout-url "https://localhost:7175/signout-callback-oidc" \ + --web-implicit-grant true false \ + --required-resource-access '[{"resourceAppId":"$($apiApp.appId)","resourceAccess":[{"id":"402f77af-bdd3-47c5-8099-5d6c74f53749","type":"Scope"},{"id":"d64bfa41-ba81-4ef1-9169-57aa151a86bd","type":"Scope"},{"id":"086556f6-1051-4992-a29a-c91009af0d6e","type":"Scope"},{"id":"c72c07cb-c9e7-4330-8824-04579ae87e84","type":"Scope"},{"id":"ac540b65-7a26-445d-9bb6-9667d0903ad1","type":"Scope"},{"id":"f5ba6a75-8d09-4791-bfd2-ed58faf7a11d","type":"Scope"},{"id":"43d8d1d6-c30b-4528-bf25-e57a14939561","type":"Scope"}]},{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0","type":"Scope"},{"id":"14dad69e-099b-42c9-810b-d002981feec1","type":"Scope"},{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' \ + --query "appId" -o tsv + $webAppId = $webApp + Write-Host "Created Web app registration with appId: $webAppId" + # Add app role + az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"DigitalInsights.Admin"}]' + # Add optional claims for idToken + $claims = @("ctry","email","upn","ipaddr","family_name","given_name","preferred_username") + foreach ($claim in $claims) { + az ad app update --id $webAppId --optional-claims-id-token "[{\"name\":\"$claim\",\"essential\":false}]" + } + # Create client secret + $webSecret = az ad app credential reset --id $webAppId --display-name "blazor-dev-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv + Write-Host "Created client secret for Web app registration." + # Set permissions for Web app to use API as downstream OBO + # (Pre-authorize Web app in API app registration) + az ad app update --id $apiApp.appId --pre-authorized-applications "[{\"appId\":\"$webAppId\",\"permissionIds\":[\"402f77af-bdd3-47c5-8099-5d6c74f53749\",\"d64bfa41-ba81-4ef1-9169-57aa151a86bd\",\"086556f6-1051-4992-a29a-c91009af0d6e\",\"c72c07cb-c9e7-4330-8824-04579ae87e84\",\"ac540b65-7a26-445d-9bb6-9667d0903ad1\",\"f5ba6a75-8d09-4791-bfd2-ed58faf7a11d\",\"43d8d1d6-c30b-4528-bf25-e57a14939561\"]}]" + Write-Host "Pre-authorized Web app in API app registration." +} else { + Write-Host "Web app registration $WebAppRegistrationName already exists." +} + +# Step 6: Write Web EEID values to Presentation.Blazor via dotnet user-secrets +Write-Host "Setting EntraExternalId values for Presentation.Blazor..." +Push-Location $WebProjectPath +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl +dotnet user-secrets set "EntraExternalId:TenantId" $TenantId +dotnet user-secrets set "EntraExternalId:ClientId" $webApp.appId +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +dotnet user-secrets set "EntraExternalId:ClientSecret" $webSecret +Pop-Location From 49ad3a3ad48ed61b9424176c520b3d5802a0d784 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 30 Jan 2026 12:39:30 -0800 Subject: [PATCH 2/9] ready to test --- .azure/scripts/New-EeidAppRegistrations.ps1 | 79 +- .azure/scripts/System.psm1 | 1483 +++++++++++++++++ .../New-EeidAppRegistrations.ps1 | 0 .../New-Github-Azure-Federation.ps1 | 0 .../scripts/hub-and-spoke/New-PlatformHub.ps1 | 32 + .../hub-and-spoke/New-PlatformSpoke.ps1 | 27 + .../scripts/hub-and-spoke/New-VnetPeering.ps1 | 55 + 7 files changed, 1651 insertions(+), 25 deletions(-) create mode 100644 .azure/scripts/System.psm1 create mode 100644 .azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 create mode 100644 .azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-PlatformHub.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 create mode 100644 .azure/scripts/hub-and-spoke/New-VnetPeering.ps1 diff --git a/.azure/scripts/New-EeidAppRegistrations.ps1 b/.azure/scripts/New-EeidAppRegistrations.ps1 index 83b6c25..773d454 100644 --- a/.azure/scripts/New-EeidAppRegistrations.ps1 +++ b/.azure/scripts/New-EeidAppRegistrations.ps1 @@ -9,7 +9,9 @@ param( [string]$WebProjectPath = "../../src/Presentation.Blazor", [string]$ApiAppRegistrationName = "api-semker-dev", [string]$ApiProjectPath = "../../src/Presentation.WebApi", - [string]$DotNetVersion = "10" + [string]$DotNetVersion = "10", + [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", + [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" ) # Step 1: Install prerequisites (az cli, dotnet sdk, modules) @@ -76,33 +78,43 @@ if (-not $apiApp) { --query "appId" -o tsv $apiAppId = $apiApp Write-Host "Created API app registration with appId: $apiAppId" - # Add permission scopes + # Add permission scopes and capture their IDs + $scopeIdMap = @{} $scopes = @( - @{name="digitalinsights.admin"; adminConsentDisplayName="Admin digital insights"; adminConsentDescription="Allows the app to administrate digital insights features, templates, connectors for a signed-in user's digital insights"; userConsentDisplayName="Admin your digital insights"; userConsentDescription="Allows the app to administrate digital insights features, templates, connectors for a digital insights"}, - @{name="activity.execute"; adminConsentDisplayName="Execute feature activity triggers"; adminConsentDescription="Allows the app to execute a feature activity for digital assets"; userConsentDisplayName="Execute a feature"; userConsentDescription="Allows the app to execute a feature activity for your digital assets"}, - @{name="features.read"; adminConsentDisplayName="View feature configuration and results"; adminConsentDescription="Allows the app to view the signed-in user's feature configuration and results"; userConsentDisplayName="Read your features"; userConsentDescription="Allows the app to read your enrolled features"}, - @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to read asset profiles and metadata"; userConsentDisplayName="Read your Assets"; userConsentDescription="Allows the app to read your asset profiles and metadata"}, - @{name="features.enroll"; adminConsentDisplayName="Enrolls features and write their setups"; adminConsentDescription="Allows enrollment of features, data sources and configurations."; userConsentDisplayName="Enroll any feature"; userConsentDescription="Allows app to eny any features"}, - @{name="analytics.read"; adminConsentDisplayName="Read analytics"; adminConsentDescription="Allows the app to read analytics for your digital assets"; userConsentDisplayName="Read your digital assets analytics"; userConsentDescription="Allows the app to read analytics for your digital assets"}, - @{name="assets.write"; adminConsentDisplayName="Read/Write assets"; adminConsentDescription="Allows the app to read and write to the signed in user's digital assets."; userConsentDisplayName="Read/Write your digital assets"; userConsentDescription="Allows the app to read/write your digital assets."} + @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to view asset data."; userConsentDisplayName="Read your assets"; userConsentDescription="Allows the app to view your assets."}, + @{name="assets.write"; adminConsentDisplayName="Edit assets"; adminConsentDescription="Allows the app to create or update asset data."; userConsentDisplayName="Edit your assets"; userConsentDescription="Allows the app to create or update your assets."}, + @{name="assets.delete"; adminConsentDisplayName="Delete assets"; adminConsentDescription="Allows the app to delete asset data."; userConsentDisplayName="Delete your assets"; userConsentDescription="Allows the app to delete your assets."} ) foreach ($scope in $scopes) { - az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription + $scopeResult = az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription --query "id" -o tsv + $scopeIdMap[$scope.name] = $scopeResult } # Add app roles $roles = @( - @{value="AnalyticsReader"; displayName="Analytics Readers"; description="Analytic Readers can view analytics from discovery"}, - @{value="DiscoveryExecuter"; displayName="Discovery Executers"; description="Discover Executer start a discovery process"}, - @{value="FeatureManager"; displayName="Feature Managers"; description="Feature Manager can alter definitions for features and data sources."} + @{value="AssetViewer"; displayName="Asset Viewer"; description="Can view assets only."}, + @{value="AssetEditor"; displayName="Asset Editor"; description="Can view and edit assets."}, + @{value="AssetAdmin"; displayName="Asset Admin"; description="Can view, edit, and delete assets."} ) foreach ($role in $roles) { az ad app update --id $apiAppId --app-roles "[{\"allowedMemberTypes\":[\"User\"],\"description\":\"$($role.description)\",\"displayName\":\"$($role.displayName)\",\"isEnabled\":true,\"origin\":\"Application\",\"value\":\"$($role.value)\"}]" } + # Query the app registration to get all scope IDs + $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json + $apiScopes = @{} + foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { + $apiScopes[$scope.value] = $scope.id + } } else { Write-Host "API app registration $ApiAppRegistrationName already exists." + $apiAppId = $apiApp.appId + $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json + $apiScopes = @{} + foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { + $apiScopes[$scope.value] = $scope.id + } } -# Step 4: Write API EEID values to Presentation.WebApi via dotnet user-secrets +# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets Write-Host "Setting EntraExternalId values for $ApiProjectPath" Push-Location $ApiProjectPath dotnet user-secrets init @@ -117,35 +129,52 @@ Write-Host "Checking for Web app registration: $WebAppRegistrationName..." $webApp = az ad app list --display-name $WebAppRegistrationName --query "[0]" -o json | ConvertFrom-Json if (-not $webApp) { Write-Host "Web app registration not found. Creating..." - $webApp = az ad app create --display-name $WebAppRegistrationName \ - --sign-in-audience AzureADMyOrg \ - --web-redirect-uris "https://localhost:7175/signin-oidc" \ - --web-logout-url "https://localhost:7175/signout-callback-oidc" \ - --web-implicit-grant true false \ - --required-resource-access '[{"resourceAppId":"$($apiApp.appId)","resourceAccess":[{"id":"402f77af-bdd3-47c5-8099-5d6c74f53749","type":"Scope"},{"id":"d64bfa41-ba81-4ef1-9169-57aa151a86bd","type":"Scope"},{"id":"086556f6-1051-4992-a29a-c91009af0d6e","type":"Scope"},{"id":"c72c07cb-c9e7-4330-8824-04579ae87e84","type":"Scope"},{"id":"ac540b65-7a26-445d-9bb6-9667d0903ad1","type":"Scope"},{"id":"f5ba6a75-8d09-4791-bfd2-ed58faf7a11d","type":"Scope"},{"id":"43d8d1d6-c30b-4528-bf25-e57a14939561","type":"Scope"}]},{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0","type":"Scope"},{"id":"14dad69e-099b-42c9-810b-d002981feec1","type":"Scope"},{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' \ + $apiResourceAccess = @() + foreach ($scopeName in $apiScopes.Keys) { + $apiResourceAccess += @{ id = $apiScopes[$scopeName]; type = "Scope" } + } + $requiredResourceAccess = @( + @{ resourceAppId = $apiAppId; resourceAccess = $apiResourceAccess }, + @{ resourceAppId = "00000003-0000-0000-c000-000000000000"; resourceAccess = @( + @{ id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"; type = "Scope" }, + @{ id = "14dad69e-099b-42c9-810b-d002981feec1"; type = "Scope" }, + @{ id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type = "Scope" } + ) } + ) | ConvertTo-Json -Compress + $webApp = az ad app create --display-name $WebAppRegistrationName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $WebRedirectUri ` + --web-logout-url $WebLogoutUri ` + --web-implicit-grant true false ` + --required-resource-access $requiredResourceAccess ` --query "appId" -o tsv $webAppId = $webApp Write-Host "Created Web app registration with appId: $webAppId" # Add app role - az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"DigitalInsights.Admin"}]' + az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"admin"}]' # Add optional claims for idToken $claims = @("ctry","email","upn","ipaddr","family_name","given_name","preferred_username") foreach ($claim in $claims) { az ad app update --id $webAppId --optional-claims-id-token "[{\"name\":\"$claim\",\"essential\":false}]" } # Create client secret - $webSecret = az ad app credential reset --id $webAppId --display-name "blazor-dev-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv + $webSecret = az ad app credential reset --id $webAppId --display-name "$WebAppRegistrationName-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv Write-Host "Created client secret for Web app registration." # Set permissions for Web app to use API as downstream OBO # (Pre-authorize Web app in API app registration) - az ad app update --id $apiApp.appId --pre-authorized-applications "[{\"appId\":\"$webAppId\",\"permissionIds\":[\"402f77af-bdd3-47c5-8099-5d6c74f53749\",\"d64bfa41-ba81-4ef1-9169-57aa151a86bd\",\"086556f6-1051-4992-a29a-c91009af0d6e\",\"c72c07cb-c9e7-4330-8824-04579ae87e84\",\"ac540b65-7a26-445d-9bb6-9667d0903ad1\",\"f5ba6a75-8d09-4791-bfd2-ed58faf7a11d\",\"43d8d1d6-c30b-4528-bf25-e57a14939561\"]}]" + $permissionIds = @() + foreach ($scopeName in $apiScopes.Keys) { + $permissionIds += $apiScopes[$scopeName] + } + $preAuthApps = @(@{ appId = $webAppId; permissionIds = $permissionIds }) | ConvertTo-Json -Compress + az ad app update --id $apiAppId --pre-authorized-applications $preAuthApps Write-Host "Pre-authorized Web app in API app registration." } else { Write-Host "Web app registration $WebAppRegistrationName already exists." } -# Step 6: Write Web EEID values to Presentation.Blazor via dotnet user-secrets -Write-Host "Setting EntraExternalId values for Presentation.Blazor..." +# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets +Write-Host "Setting EntraExternalId values for $WebProjectPath" Push-Location $WebProjectPath dotnet user-secrets init dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl diff --git a/.azure/scripts/System.psm1 b/.azure/scripts/System.psm1 new file mode 100644 index 0000000..5768629 --- /dev/null +++ b/.azure/scripts/System.psm1 @@ -0,0 +1,1483 @@ +#----------------------------------------------------------------------- +# Add-Prefix [-String []] +# +# Example: .\Add-Prefix -String ello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Add-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Prefix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsFirst -String $String -BeginsWith $Add)) + { + $ReturnValue = ($Add + $ReturnValue) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Add $Add" + } + return $ReturnValue +} +export-modulemember -function Add-Prefix + +#----------------------------------------------------------------------- +# Add-Suffix [-String []] +# +# Example: .\Add-Suffix -String Hell -Add o +# Result: Hello +#----------------------------------------------------------------------- +function Add-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Add = $(throw '-Add is a required parameter.') + ) + Write-Verbose "Add-Suffix -String $String -Add $Add" + [string]$ReturnValue = $String + if (-not (Compare-IsLast -String $String -EndsWith $Add)) + { + $ReturnValue = ($String + $Add) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Add $Add" + } + + return $ReturnValue +} +export-modulemember -function Add-Suffix + +#----------------------------------------------------------------------- +# Compare-IsFirst [-String []] +# +# Example: .\Compare-IsFirst -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsFirst -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsFirst +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$BeginsWith = $(throw '-BeginsWith is a required parameter.') + ) + + Write-Verbose "Compare-IsFirst -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($BeginsWith.Length -lt $String.Length) + { + $StringBeginning = $String.SubString(0, $BeginsWith.Length).ToLower() + if ($StringBeginning.ToLower().Equals($BeginsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Compare-IsFirst + +#----------------------------------------------------------------------- +# Compare-IsLast [-String []] +# +# Example: .\Compare-IsLast -String Hell -EndsWith H +# Result: false +# Example: .\Compare-IsLast -String Hello -Add H +# Result: Hello +#----------------------------------------------------------------------- +function Compare-IsLast +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$EndsWith = $(throw '-EndsWith is a required parameter.') + ) + Write-Verbose "Compare-IsLast -String $String -EndsWith $EndsWith" + [Boolean]$ReturnValue = $false + if($EndsWith.Length -lt $String.Length) + { + $StringEnding = $String.SubString(($String.Length - $EndsWith.Length), $EndsWith.Length) + if ($StringEnding.ToLower().Equals($EndsWith.ToLower())) + { + $ReturnValue = $true + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + + return $ReturnValue +} +export-modulemember -function Compare-IsLast + +#----------------------------------------------------------------------- +# Compress-Path [-Path []] [-File []] +# +# Example: .\Compress-Path \\source\path \\destination\path\file.zip +#----------------------------------------------------------------------- +function Compress-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Compress-Path -Path $Path -File $File" + New-Path -Path $Path + Remove-File $File + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::CreateFromDirectory($Path, $File) +} +export-modulemember -function Compress-Path + +#----------------------------------------------------------------------- +# Convert-PathSafe [-Path []] +# +# Example: .\Convert-PathSafe -Path \\source\path +#----------------------------------------------------------------------- +function Convert-PathSafe +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Convert-PathSafe -Path $Path" + $Path = $Path.Trim() + $ReturnValue = $Path + $Path = Set-Unc -Path $Path + if(Test-Path -Path $Path) + { + $ReturnValue = Convert-Path -Path $Path + if (-not ($ReturnValue)) + { + $ReturnValue = $Path + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path didnt convert." + } + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Convert-PathSafe + +#----------------------------------------------------------------------- +# Copy-Backup [-Path []] [-Destination []] +# +# Example: .\Copy-Backup -Path \\source\path -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-Backup +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.') + ) + Write-Verbose "Copy-Backup -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + New-Path -Path $Destination + [String]$BackupPath=[string]::Format("{0}\{1}", $Destination, (Get-Date).ToString("yyyy-MM-dd")) + if($Path) + { + if(-not (Test-Path -Path $BackupPath -PathType Container)){ + New-Path -Path $BackupPath + } + Copy-Recurse -Path $Path -Destination $BackupPath + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $BackupPath" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Backup + +#----------------------------------------------------------------------- +# Copy-File [-Path []] [-Destination []] +# +# Example: .\Copy-File -Path \\source\path\File.name -Destination \\destination\path +#----------------------------------------------------------------------- +function Copy-File +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory = $True)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [bool]$Overwrite = $true + ) + Write-Verbose "Copy-File -Path $Path -Destination $Destination -Overwrite $Overwrite" + $Destination = Set-Unc -Path $Destination + if(Test-File -Path $Path) + { + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Folder -Path $Destination) + { + $DestinationAbsolute = Convert-PathSafe -Path $Destination + } + $DestinationPathFile = $DestinationAbsolute + $FolderArray = $Path.Split('\') + if($FolderArray.Count -gt 0) + { + $DestinationPathFile = Join-Path $DestinationAbsolute $FolderArray[$FolderArray.Count-1] + } + if((-not (Test-Path $DestinationPathFile -PathType Leaf)) -or ($Overwrite -eq $true)) + { + try{ + Copy-Item -Path $Path -Destination $DestinationAbsolute -Include $Include -Exclude $Exclude -Force + } + catch{ + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $DestinationAbsolute" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-File + +#----------------------------------------------------------------------- +# Copy-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Copy-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Copy-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000, + [bool]$Overwrite=$True, + [bool]$Clean = $False + ) + Write-Verbose "Copy-Recurse -Path $Path -Destination $Destination -Include $Include -Exclude $Exclude -First $ -Overwrite $Overwrite -Clean $Clean" + $Affected = 0 + $Path = Set-Unc -Path $Path + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + # Optionally Clean + if($Clean -eq $True) { Remove-Path -Path $Destination } + New-Path -Path $Destination + $DestinationAbsolute = $Destination + if(Test-Path $Destination) { $DestinationAbsolute=Convert-PathSafe -Path $Destination } + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + ForEach ($Item in $Items) { + $PathArray = $PathAbsolute.Split('\') + $Folder = $DestinationAbsolute + for ($count=1; $count -lt $PathArray.length-1; $count++) { + $Subfolder = $PathArray[$count] + $Folder = Join-Path $Folder $Subfolder + if (($Folder.Length > 0) -and (-not (Test-Path $Folder))) { + Write-Verbose "New-Item -ItemType directory -Force -Path $Folder" + New-Item -ItemType directory -Force -Path $Folder + } + } + $DirName = $Item.DirectoryName + $Position = $DirName.IndexOf($PathAbsolute) + $PathSegment = $DirName.SubString($Position + $PathAbsolute.Length) + $NewPath = Join-Path $DestinationAbsolute $PathSegment + Copy-File -Path $Item.FullName -Destination $NewPath -Overwrite $Overwrite + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Copy-Recurse + +#----------------------------------------------------------------------- +# Expand-File [-Path []] [-File []] +# +# Example: .\Expand-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Expand-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.') + ) + Write-Verbose "Expand-Zip -Path $Path -File $File" + New-Path -Path $Path + [Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") + [System.IO.Compression.ZipFile]::ExtractToDirectory($File, $Path) +} +export-modulemember -function Expand-File + +#----------------------------------------------------------------------- +# Find-File [-Path []] [-File []] +# +# Example: .\Find-File \\source\path\file.zip \\destination\path +#----------------------------------------------------------------------- +function Find-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$File=$(throw '-File is a required parameter.'), + [Int32]$First=1 + ) + Write-Verbose "Find-Zip -Path $Path -File $File" + Get-Childitem -Path $Path -Include $File -Recurse| select -First $First +} +export-modulemember -function Find-File + +#----------------------------------------------------------------------- +# Get-AssemblyStrongName +# +# Example: Get-AssemblyStrongName +#----------------------------------------------------------------------- +function Get-AssemblyStrongName($assemblyPath) +{ + [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).FullName +} +export-modulemember -function Get-AssemblyStrongName + +#----------------------------------------------------------------------- +# Get-CurrentLine +# +# Example: Get-CurrentLine +#----------------------------------------------------------------------- +function Get-CurrentLine { + $MyInvocation.ScriptLineNumber +} +export-modulemember -function Get-CurrentLine + +#----------------------------------------------------------------------- +# Get-CurrentFile +# +# Example: Get-CurrentFile +#----------------------------------------------------------------------- +function Get-CurrentFile { + $MyInvocation.ScriptName +} +export-modulemember -function Get-CurrentFile + +#----------------------------------------------------------------------- +# Get-FilesByString +# +# Example: Get-FilesByString +#----------------------------------------------------------------------- +function Get-FilesByString { + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$String = $(throw '-String is a required parameter.'), + [string[]]$Include = ("*.*"), + [string[]]$Exclude = "" + ) + Write-Host "Get-FilesByString -Path $Path -String $String -Include $Include -Exclude $Exclude" + $Path = Set-Unc -Path $Path + + $ReturnData = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Select-String -pattern $String | group path | select name + + return $ReturnData +} +export-modulemember -function Get-FilesByString + +#----------------------------------------------------------------------- +# Get-SystemFolders +# +# Example: Get-SystemFolder +#----------------------------------------------------------------------- +function Get-SystemFolders +{ + param ( + ) + Write-Verbose "Get-SystemFolders" + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + } + return $SpecialFolders +} +export-modulemember -function Get-SystemFolders + +#----------------------------------------------------------------------- +# Get-SystemFolder [-Name []] +# Keys: Desktop,Programs,MyDocuments,Personal,Favorites,Startup,Recent,SendTo,StartMenu,MyMusic,MyVideos,DesktopDirectory,NetworkShortcuts,Fonts +# Templates,CommonStartMenu,CommonPrograms,CommonStartup,CommonDesktopDirectory,ApplicationData,PrinterShortcuts,LocalApplicationData,InternetCache +# Cookies,History,CommonApplicationData,Windows,System,ProgramFiles,MyPictures,UserProfile,SystemX86,ProgramFilesX86 +# CommonProgramFiles,CommonProgramFilesX86,CommonTemplates,CommonDocuments,CommonAdminTools,AdminTools,CommonMusic,CommonPictures,CommonVideos,ResourcesCDBurning +# Example: Get-SystemFolder -Name 'UserProfile' +#----------------------------------------------------------------------- +function Get-SystemFolder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Name=$(throw '-Folder is a required parameter.') + ) + Write-Verbose "Get-SystemFolder -Folder $Folder" + if($path = [Environment]::GetFolderPath($name)){ + $Folder = $path + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $Folder +} +export-modulemember -function Get-SystemFolder + +#----------------------------------------------------------------------- +# Move-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Move-Path -Path \\source\path +#----------------------------------------------------------------------- +function Move-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Move-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) + { + Remove-Path -Destination $Destination + New-Path -Destination $Destination + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Remove-Path -Destination $Path + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path moved to -Destination $Destination" + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Move-Path + +#----------------------------------------------------------------------- +# New-Path [-Path []] +# +# Example: .\New-Path \\source\path +#----------------------------------------------------------------------- +function New-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$Clean=$false + ) + Write-Verbose "New-Path -Path $Path" + [String]$Folder = "" + $Path = Remove-Suffix -String $Path -Remove "\" + if ($Clean) {Remove-Path -Path $Path} + if (-not (test-path $Path)) { + if (Test-Unc $Path) + { + $PathArray = $Path.Split('\') + foreach($item in $PathArray) + { + if($item.Length -gt 0) + { + if($Folder.Length -lt 1) + { + $Folder = "\\$item" + } + else + { + $Folder = "$Folder\$item" + if (-not (Test-Path $Folder)) { + New-Item -ItemType directory -Path $Folder -Force + } + } + } + } + } + else + { + New-Item -ItemType directory -Path $Path -Force + } + } +} +export-modulemember -function New-Path + +#----------------------------------------------------------------------- +# Redo-Path [-Path []] [-Destination []] +# [-Exclude []] +# +# Example: .\Redo-Path -Path \\source\path +#----------------------------------------------------------------------- +function Redo-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Destination = $(throw '-Destination is a required parameter.'), + [string]$Exclude = "" + ) + Write-Verbose "Redo-Path -Path $Path -Destination $Destination" + $Path = Remove-Suffix -String $Path -Remove "\" + Remove-Path -Destination $Path + New-Path -Destination $Path + Copy-Recurse -Path $Path -Destination $Destination -Exclude $Exclude + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path to -Destination $Destination" +} +export-modulemember -function Redo-Path + +#----------------------------------------------------------------------- +# Remove-File [-File []] +# +# Example: .\Remove-File \\source\path\file.txt +#----------------------------------------------------------------------- +function Remove-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.') + ) + Write-Verbose "Remove-File -Path $Path" + if (Test-File -Path $Path) { + Remove-Item -Path $Path -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-File + +#----------------------------------------------------------------------- +# Remove-Path [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Path -Path \\source\path +#----------------------------------------------------------------------- +function Remove-Path +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int16]$Retention = 1, + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Path -Path $Path -Include $Include -Exclude $Exclude -Retention $Retention -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (test-folder -Path $Path) { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = 'SilentlyContinue' + Get-ChildItem -Path $Path -Include $Include -Exclude $Exclude -Recurse | Where-Object {($_.PSIsContainer) -and ($_.lastwritetime -le (get-date).addDays(($Retention*-1)))} | select -First $First | Remove-Item -Force -Recurse + Remove-Item $Path -Recurse -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-Path + +#----------------------------------------------------------------------- +# Remove-Subfolders [-Path []] +# [-Include [] [-Exclude []] +# +# Example usage: +# Remove-Subfolders -Path "C:\YourProjectPath" -Subfolder "bin" +# Remove-Subfolders -Path "C:\YourProjectPath" -Subfolder "obj" +#----------------------------------------------------------------------- +function Remove-Subfolders { + param ( + [Parameter(Mandatory=$true)] + [string]$Path, + [Parameter(Mandatory=$true)] + [string]$Subfolder + ) + + Write-Verbose "Removing subfolders -Path $Path -Subfolder $Subfolder" + + if (Test-Path -Path $Path) { + $Folders = Get-ChildItem -Path $Path -Recurse -Directory -Filter $Subfolder + foreach ($Folder in $Folders) { + Remove-Item -Path $Folder.FullName -Recurse -Force + Write-Verbose "Removed $($Folder.FullName)" + } + Write-Verbose "[Success] Removed $($Folders.Count) '$Subfolder' folders." + } else { + Write-Verbose "[Warning] Path $Path does not exist." + } +} +export-modulemember -function Remove-Subfolders + +#----------------------------------------------------------------------- +# Remove-Recurse [-Source []] [-Destination []] +# [-Include [] [-Exclude []] +# +# Example: .\Remove-Recurse \\source\path \\destination\path +#----------------------------------------------------------------------- +function Remove-Recurse +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Source is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 1000 + ) + Write-Verbose "Remove-Recurse -Path $Path -Include $Include -Exclude $Exclude" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + $Items = Get-ChildItem -Path $PathAbsolute -Recurse -Include $Include -Exclude $Exclude | where { ! $_.PSIsContainer } + $Affected = 0 + ForEach ($Item in $Items) { + Remove-File -Path $Item + $Affected = $Affected + 1 + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path." + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Remove-Recurse + +#----------------------------------------------------------------------- +# Remove-Prefix [-String []] +# +# Example: .\Remove-Prefix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Prefix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Prefix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if (Compare-IsFirst -String $String -BeginsWith $Remove) + { + $ReturnValue = $String.Substring($Remove.Length, $String.Length - $Remove.Length) + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has prefix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Prefix + +#----------------------------------------------------------------------- +# Remove-Suffix [-String []] +# +# Example: .\Remove-Suffix -String Hell -Remove o +# Result: Hello +#----------------------------------------------------------------------- +function Remove-Suffix +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$String = $(throw '-String is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Remove = $(throw '-Remove is a required parameter.') + ) + Write-Verbose "Remove-Suffix -String $String -Remove $Remove" + [string]$ReturnValue = $String + if($String) + { + if (Compare-IsLast -String $String -EndsWith $Remove) + { + $ReturnValue = $ReturnValue.Substring(0, $String.Length - $Remove.Length) + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -String $String already has suffix of -Remove $Remove" + } + + return $ReturnValue +} +export-modulemember -function Remove-Suffix + +#----------------------------------------------------------------------- +# Remove-Element [-Path []] +# +# Example: .\Remove-Element -Value "" +# -XPath "//msb:None/msb:Generator" +# -Namespace @{msb = "http://schemas.microsoft.com/developer/msbuild/2003"} +# +# Called: $XMLValue = [xml](Get-Content $path) +# $Namespace = @{msb = 'http://schemas.microsoft.com/developer/msbuild/2003'} +# Remove-Element $XMLValue -XPath '//msb:None/msb:Generator' -Namespace $Namespace +# $proj.Save($path) +#----------------------------------------------------------------------- +function Remove-Element +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [xml]$Value=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$XPath=$(throw '-Value is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [String]$Namespace=$(throw '-Value is a required parameter.') + ) + Write-Verbose ".\Remove-Element -Value $Value -XPath $XPath -Namespace $Namespace -SingleNode" + $nodes = @(Select-Xml $XPath $Value -Namespace $Namespace | Foreach {$_.Node}) + if (!$nodes) { Write-Verbose "RemoveElement: XPath $XPath not found" } + if ($singleNode -and ($nodes.Count -gt 1)) { + throw "XPath $XPath found multiple nodes" + } + $Count = 0 + foreach ($node in $nodes) + { + $parentNode = $node.ParentNode + [void]$parentNode.RemoveChild($node) + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." +} +export-modulemember -function Remove-Element + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -eq $Open)) + { + $OpenIndex = $Count + } + ElseIf(($OpenIndex -gt -1) -and ($CurrentLine -eq $Close)) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -lt $CloseIndex)) + { + # Match Found Remove block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTag + +#----------------------------------------------------------------------- +# Remove-ContentsByTagContains [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Remove-ContentsByTagContains \\source\path \\destination\path +# GlobalSection(TeamFoundationVersionControl) = preSolution +# EndGlobalSection +#----------------------------------------------------------------------- +function Remove-ContentsByTagContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Verbose "Remove-ContentsByTagContains -Path $Path -Open $Open -Close $Close -Contains $Contains -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Contains = $Contains.Trim() + + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$ContainsIndex = -1 + [Int32]$CloseIndex = -1 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If ($CurrentLine -like "*$Open*") + { + If($OpenIndex -gt -1) + { + # Fail: Block did not contain -Content and/or -Open was found before -Close. Reset for next open tag match. + $ContainsIndex = -1 + $CloseIndex = -1 + } + $OpenIndex = $Count + }ElseIf($OpenIndex -gt -1) + { + If($CurrentLine -like "*$Contains*") + { + $ContainsIndex = $Count + }ElseIf(($ContainsIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + # Success, block starts with -Open, ends with -Close and includes -Contains + $CloseIndex = $Count + Break + } + } + } + # Any matches? + If(($OpenIndex -gt -1) -and ($ContainsIndex -gt $OpenIndex) -and ($CloseIndex -gt $ContainsIndex)) + { + If($CloseIndex -eq ($OpenIndex + 2)) + { + # Match Found with single element. Remove Block. + $NewContent = ($Content | Select -First $OpenIndex) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + } + Else + { + # Match Found with multiple elements. Remove Line Only. + $NewContent = ($Content | Select -First $ContainsIndex) + ($Content | select -Last ($Content.Length - $ContainsIndex -1)) + } + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Remove-ContentsByTagContains + +#----------------------------------------------------------------------- +# Rename-File [-File []] +# +# Example: .\Rename-File -Path ($StagingZipPath + 'root.vstbak') -NewName root.vstemplate -Force +#----------------------------------------------------------------------- +function Rename-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path=$(throw '-Path is a required parameter.'), + [string]$NewName=$(throw '-NewName is a required parameter.') + ) + Write-Verbose "Rename-File -Path $Path -NewName $NewName" + if (Test-File -Path $Path) { + Rename-Item -Path $Path -NewName $NewName -Force + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path removed." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Rename-File + +#----------------------------------------------------------------------- +# Set-ReadOnly [-Path []] [-ReadOnly []] +# +# Example: .\Set-ReadOnly -Path \\source\path\File.name -ReadOnly $False +#----------------------------------------------------------------------- +function Set-ReadOnly +{ + param ( + [Parameter(Mandatory = $True)] + [string]$Path = $(throw '-Path is a required parameter.'), + [bool]$ReadOnly = $True, + [string]$ErrorPreference = 'SilentlyContinue' + ) + Write-Verbose "Set-ReadOnly -Path $Path -ReadOnly $ReadOnly -ErrorPreference $ErrorPreference" + $Path = Remove-Suffix -String $Path -Remove "\" + if(test-path $Path) + { + $PathAbsolute = Convert-PathSafe -Path $Path + if (Test-Path $PathAbsolute -PathType Leaf) + { + $ErrorActionPreferenceBackup = $ErrorActionPreference + $ErrorActionPreference = $ErrorPreference + Set-ItemProperty $PathAbsolute -name IsReadOnly -value $ReadOnly -Force + $ErrorActionPreference = $ErrorActionPreferenceBackup + } + Write-Verbose "[Success] 1 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path set." + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } +} +export-modulemember -function Set-ReadOnly + +#----------------------------------------------------------------------- +# Set-SystemFolderDrives +# +# Example: Set-SystemFolderDrives +#----------------------------------------------------------------------- +function Set-SystemFolderDrives +{ + param ( + ) + Write-Verbose "Set-SystemFolderDrives" + + $SpecialFolders = @{} + $names = [Environment+SpecialFolder]::GetNames([Environment+SpecialFolder]) + foreach($name in $names) + { + if($path = [Environment]::GetFolderPath($name)){ + $SpecialFolders[$name] = $path + New-PSDrive -Name $name -PSProvider FileSystem -Root $path + } + } + # #TBD: Find the 10 Largest Files in the Documents Folder + # gci Personal: -Recurse -Force -ea SilentlyContinue | + # Sort-Object -Property Length -Descending | + # Select-Object -First 10 | + # Format-Table -AutoSize -Wrap -Property ` + # Length,LastWriteTime,FullName + return $SpecialFolders +} +export-modulemember -function Set-SystemFolderDrives + +#----------------------------------------------------------------------- +# Test-File [-Path []] +# +# Example: .\Test-File -Path \\source\path +#----------------------------------------------------------------------- +function Test-File +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-File -Path $Path" + [bool]$ReturnValue = $false + if(Test-Path -Path $Path -PathType Leaf) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a File." + } + return $ReturnValue +} +export-modulemember -function Test-File + +#----------------------------------------------------------------------- +# Test-Folder [-Path []] +# +# +# Example: .\Test-Folder -Path \\source\path +#----------------------------------------------------------------------- +function Test-Folder +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Folder -Path $Path" + [bool]$ReturnValue = $false + if(test-path -Path $Path -PathType Container) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[Warning] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist or is not a Folder." + } + return $ReturnValue +} +export-modulemember -function Test-Folder + +#----------------------------------------------------------------------- +# Test-PathEmpty [-Path []] +# +# Example: .\Test-PathEmpty -Path \\source\path +#----------------------------------------------------------------------- +function Test-PathEmpty +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-PathEmpty -Path $Path" + [bool]$ReturnValue = $false + if((Get-ChildItem $Path -force | Select-Object -First 1 | Measure-Object).Count -eq 0) + { + $ReturnValue = $true + } + else + { + Write-Host "[Error] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path does not exist." + } + return $ReturnValue +} +export-modulemember -function Test-PathEmpty + +#----------------------------------------------------------------------- +# Set-Unc [-Path []] +# +# Example: .\Set-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Set-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Set-Unc -Path $Path" + $Path = $Path.Trim() + $Path = Remove-Suffix -String $Path -Remove '\' + if(-not ($Path.Contains(':\') -or $Path.Contains('.\') -or (Compare-IsFirst -String $Path -BeginsWith '\'))) + { + $ReturnValue = Add-Prefix -String $Path -Add '\\' + } + else + { + $ReturnValue = $Path + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine). -Path $Path already a UNC, drive letter, absolute or relative path." + } + return $ReturnValue +} +export-modulemember -function Set-Unc + +#----------------------------------------------------------------------- +# Test-Unc [-Path []] +# +# +# Example: .\Test-Unc -Path \\source\path +#----------------------------------------------------------------------- +function Test-Unc +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.') + ) + Write-Verbose "Test-Unc -Path $Path" + [bool]$ReturnValue = $false + if(($Path.Contains('\\')) -and (-not ($Path.Contains(':\')))) + { + $ReturnValue = $true + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + return $ReturnValue +} +export-modulemember -function Test-Unc + +#----------------------------------------------------------------------- +# Update-LineByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-LineByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-LineByContains +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Contains = $(throw '-Contains is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Line = $(throw '-Line is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-LineByContains -Path $Path -Contains $Contains -Line $Line -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$ContainsIndex = -1 + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($ContainsIndex -eq -1) -and ($CurrentLine -eq $Contains)) + { + $ContainsIndex = $Count + Break + } + } + # Evaluate search + If($ContainsIndex -gt -1) + { + # Select before line, add -Line, select after line + $NewContent = (($Content | Select -First $ContainsIndex) + ($Line + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $ContainsIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-LineByContains + +#----------------------------------------------------------------------- +# Update-ContentsByTag [-Path []] +# [-Open [] [-Close []] +# +# Example: .\Update-ContentsByTag -Path $Path -Include *.sln -Open "GlobalSection(TeamFoundationVersionControl) = preSolution" -Close "EndGlobalSection" +#----------------------------------------------------------------------- +function Update-ContentsByTag +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Open = $(throw '-Open is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Close = $(throw '-Close is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Value = $(throw '-Value is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-ContentsByTag -Path $Path -Open $Open -Close $Close -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + [String]$PaddingLeft = ' ' + if (Test-Path $Path) + { + $Open = $Open.Trim() + $Close = $Close.Trim() + $Affected = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$OpenIndex = -1 + [Int32]$CloseIndex = -1 + [String]$NewValue = "" + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($OpenIndex -eq -1) -and ($CurrentLine -like "*$Open*")) + { + $OpenIndex = $Count + } + If(($OpenIndex -gt -1) -and ($CurrentLine -like "*$Close*")) + { + $CloseIndex = $Count + Break + } + } + # Evaluate search + If(($OpenIndex -gt -1) -and ($OpenIndex -le $CloseIndex)) + { + if($OpenIndex -eq $CloseIndex) + { + # Open/Close on same line, rebuild the line with new contents + $NewValue = ($Open + $Value + $Close) + } + else + { + $NewValue = $Value + } + # Update content + $NewContent = ($Content | Select -First ($OpenIndex)) + ($PaddingLeft + $NewValue) + ($Content | select -Last ($Content.Length - $CloseIndex - 1)) + $Affected = 1 + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + } + Write-Verbose "[Success] $Affected items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-ContentsByTag + +#----------------------------------------------------------------------- +# Update-Text [-Path []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-Text -Path \\source\path -Include *.cs -Old "Use gotos" -New "Point at people who use gotos" +#----------------------------------------------------------------------- +function Update-Text +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Old = $(throw '-Old is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$New = $(throw '-New is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + $Count = 0 + if (Test-Path $Path) + { + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + foreach ($Item in $ConfigFiles) + { + Set-ReadOnly -Path $Item.PSPath -ReadOnly $false + (Get-Content $Item.PSPath) | + Foreach-Object {$_.Replace($Old, $New) + } | + Set-Content $Item.PSPath -force + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-Text + +#----------------------------------------------------------------------- +# Update-TextByContains [-Path []] +# [-Contains [] [-Close []] +# +# Example: .\Update-TextByContains -Path \\source\path -Include AssemblyInfo.cs -Contains 'AssemblyVersion(' -Line '[assembly: AssemblyVersion("5.20.07")]' +#----------------------------------------------------------------------- +function Update-TextByContains +{ + param ( + [string]$Path = $(throw '-Path is a required parameter.'), + [string]$Contains = $(throw '-Contains is a required parameter.'), + [string]$Old = $(throw '-Old is a required parameter.'), + [string]$New = $(throw '-New is a required parameter.'), + [string[]]$Include = "*.*", + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-TextByContains -Path $Path -Contains $Contains -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + $Contains = $Contains.Trim() + $Count = 0 + $Files = Get-Childitem -Path $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + ForEach ($File in $Files) + { + [Int32]$FoundIndex = -1 + [String]$FoundLine = '' + $Affected = 0 + $Content=Get-Content $File.PSPath + # Search for matches + For([Int32]$Count = 0; $Count -lt $Content.Length; $Count++) + { + $CurrentLine = $Content[$Count].Trim() + If(($FoundIndex -eq -1) -and ($CurrentLine.ToLowerInvariant().Contains($Contains.ToLowerInvariant()))) + { + $FoundIndex = $Count + $FoundLine = $CurrentLine + Break + } + } + # Evaluate search + If($FoundIndex -gt -1) + { + # Replace text inside of line + $NewLine = $FoundLine.Replace($Old, $New) + # Select before line, add $NewLine, select after line + $NewContent = (($Content | Select -First $FoundIndex) + ($NewLine + [Environment]::NewLine) + ($Content | select -Last ($Content.Length - $FoundIndex -1))) + } + else + { + # No Match Found + $NewContent = $Content + } + Set-Content $File.PSPath -Value $NewContent + $Affected = $Count + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByContains + +#----------------------------------------------------------------------- +# Update-TextByTable [-Path []] [-Replace []] +# [-Include [] [-Exclude []] +# +# Example: .\Update-TextByTable -Path \\source\path -Include *.cs +# -Replace @{'Old1' = 'New1' +# 'Old2' = 'New2' +# 'Old3' = 'New3'} +#----------------------------------------------------------------------- +function Update-TextByTable +{ + param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Path = $(throw '-Path is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [hashtable]$Replace = $(throw '-Replace is a required parameter.'), + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string[]]$Include = $(throw '-Include is a required parameter.'), + [string[]]$Exclude = "", + [Int32]$First = 100 + ) + Write-Host "Update-Text -Path $Path -Old $Old -New $New -Include $Include -Exclude $Exclude -First $First" + $Path = Remove-Suffix -String $Path -Remove "\" + if (Test-Path $Path) + { + Write-Verbose "Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First" + $ConfigFiles=Get-Childitem $Path -Include $Include -Exclude $Exclude -Recurse -Force | select -First $First + Write-Verbose "ConfigFiles: $ConfigFiles" + $Count = 0 + foreach ($Item in $ConfigFiles) + { + Write-Verbose "Get-Content $Item.PSPath" + $fileLines = Get-Content $Item.PSPath + Write-Verbose "fileLines: $fileLines" + if($fileLines) + { + foreach($replaceItem in $Replace.GetEnumerator()) { + Write-Verbose "$fileLines.Replace($replaceItem.Key, $replaceItem.Value)" + $fileLines = $fileLines.Replace($replaceItem.Key, $replaceItem.Value) + } + Write-Verbose "Set-Content -Path $Item.PSPath -Value $fileLines -force" + Set-Content -Path $Item.PSPath -Value $fileLines -force + } + $Count = $Count + 1 + } + Write-Verbose "[Success] $Count items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } + else + { + Write-Verbose "[OK] 0 items affected. $(Get-CurrentFile) at $(Get-CurrentLine)." + } +} +export-modulemember -function Update-TextByTable \ No newline at end of file diff --git a/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 b/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/.azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 b/.azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 new file mode 100644 index 0000000..e69de29 diff --git a/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 b/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 new file mode 100644 index 0000000..c783e57 --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-PlatformHub.ps1 @@ -0,0 +1,32 @@ +# Login and set subscription variables +az login + +# Sentinel requires registration +az provider register --namespace Microsoft.OperationsManagement +az provider register --namespace Microsoft.SecurityInsights + + +$mgmtRg = "COMPANY-hubmgmt-wus2-001-rg" +$mgmtTemplate = "../bicep/templates/platform-hub-mgmt.bicep" +$mgmtParams = "../bicep/variables/platform-hub-mgmt.bicepparam" +$networkRg = "COMPANY-hubnetwork-wus2-001-rg" +$networkTemplate = "../bicep/templates/platform-hub-network-publicroute.bicep" +$networkParams = "../bicep/variables/platform-hub-network-publicroute.bicepparam" +$hubSubId = "" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $hubSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} diff --git a/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 b/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 new file mode 100644 index 0000000..a303a0b --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-PlatformSpoke.ps1 @@ -0,0 +1,27 @@ +# Login and set subscription variables +az login + +$mgmtRg = "COMPANY-hubmgmt-wus2-001-rg" +$mgmtTemplate = "../bicep/templates/platform-spoke-mgmt.bicep" +$mgmtParams = "../bicep/variables/platform-spoke-mgmt.bicepparam" +$networkRg = "COMPANY-hubnetwork-wus2-001-rg" +$networkTemplate = "../bicep/templates/platform-spoke-network.bicep" +$networkParams = "../bicep/variables/platform-spoke-network.bicepparam" +$spokeSubId = "" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $spokeSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} diff --git a/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 b/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 new file mode 100644 index 0000000..52e3ee9 --- /dev/null +++ b/.azure/scripts/hub-and-spoke/New-VnetPeering.ps1 @@ -0,0 +1,55 @@ +# Log in to Azure +az login + +# Login +az login + +# Variables for resource groups, templates, and parameters +$mgmtRg = "can-hubmgmt-plat-wus2-001-rg" +$mgmtTemplate = "bicep/templates/platform-hub-publicroute-mgmt.bicep" +$mgmtParams = "bicep/variables/platform-hub-publicroute-mgmt.bicepparam" +$networkRg = "can-hubnetwork-plat-wus2-001-rg" +$networkTemplate = "bicep/templates/platform-hub-publicroute-network.bicep" +$networkParams = "bicep/variables/platform-hub-publicroute-network.bicepparam" +$hubSubId = "" +$spokeSubId = "" +$spokeRg = "" +$hubVnetName = "" +$spokeVnetName = "" +$hubVnetResourceId = "/subscriptions/$hubSubId/resourceGroups/$networkRg/providers/Microsoft.Network/virtualNetworks/$hubVnetName" +$spokeVnetResourceId = "/subscriptions/$spokeSubId/resourceGroups/$spokeRg/providers/Microsoft.Network/virtualNetworks/$spokeVnetName" +$spokeToHubPeeringName = "spoke-to-hub" +$hubToSpokePeeringName = "hub-to-spoke" +$peeringTemplate = "bicep/modules/vnetpeer-virtualnetworkpeering.bicep" + +# Create resource groups if not exist +az group create --name $mgmtRg --location westus2 +az group create --name $networkRg --location westus2 +az group create --name $spokeRg --location westus2 + +# Management group deployment: what-if, then deploy if OK +az account set --subscription $hubSubId +az deployment group what-if --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $mgmtRg --template-file $mgmtTemplate --parameters @$mgmtParams +} + +# Network group deployment: what-if, then deploy if OK +az deployment group what-if --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +if ($LASTEXITCODE -eq 0) { + az deployment group create --resource-group $networkRg --template-file $networkTemplate --parameters @$networkParams +} + +# Spoke to Hub Peering +az account set --subscription $spokeSubId +az deployment group create ` + --resource-group $spokeRg ` + --template-file $peeringTemplate ` + --parameters localVnetName=$spokeVnetName peeringName=$spokeToHubPeeringName remoteVnetId=$hubVnetResourceId allowGatewayTransit=false useRemoteGateways=true allowVnetAccess=true allowForwardedTraffic=false + +# Hub to Spoke Peering +az account set --subscription $hubSubId +az deployment group create ` + --resource-group $networkRg ` + --template-file $peeringTemplate ` + --parameters localVnetName=$hubVnetName peeringName=$hubToSpokePeeringName remoteVnetId=$spokeVnetResourceId allowGatewayTransit=true useRemoteGateways=false allowVnetAccess=true allowForwardedTraffic=false From 6b62da72e19468a13345548e4f2d635a9ebe0780 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 30 Jan 2026 12:43:25 -0800 Subject: [PATCH 3/9] script to official folder --- .azure/scripts/New-EeidAppRegistrations.ps1 | 185 ------------------ .../New-EeidAppRegistrations.ps1 | 185 ++++++++++++++++++ 2 files changed, 185 insertions(+), 185 deletions(-) delete mode 100644 .azure/scripts/New-EeidAppRegistrations.ps1 diff --git a/.azure/scripts/New-EeidAppRegistrations.ps1 b/.azure/scripts/New-EeidAppRegistrations.ps1 deleted file mode 100644 index 773d454..0000000 --- a/.azure/scripts/New-EeidAppRegistrations.ps1 +++ /dev/null @@ -1,185 +0,0 @@ -# EEID App Registration and Secret Setup Script -# Location: .azure/scripts/New-EeidAppRegistrations.ps1 -# Description: Idempotently installs prerequisites, creates/updates EEID app registrations, sets permissions, and configures dotnet user-secrets for Presentation.WebApi and Presentation.Blazor. -param( - [string]$EeIdInstanceUrl, - [string]$TenantId, - [string]$SubscriptionId, - [string]$WebAppRegistrationName = "web-semker-dev", - [string]$WebProjectPath = "../../src/Presentation.Blazor", - [string]$ApiAppRegistrationName = "api-semker-dev", - [string]$ApiProjectPath = "../../src/Presentation.WebApi", - [string]$DotNetVersion = "10", - [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", - [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" -) - -# Step 1: Install prerequisites (az cli, dotnet sdk, modules) -Write-Host "Checking prerequisites..." - -# Check and install Azure CLI -if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Host "Azure CLI not found. Installing via winget..." - winget install --id Microsoft.AzureCLI -e --silent -} else { - Write-Host "Azure CLI is already installed." -} - -# Check and install .NET SDK -$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." -if (-not $dotnetInstalled) { - Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." - winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent -} else { - Write-Host ".NET SDK $DotNetVersion is already installed." -} - -# Check and install PowerShell modules -$modules = @("Az.Accounts", "Az.Resources") -foreach ($module in $modules) { - if (-not (Get-Module -ListAvailable -Name $module)) { - Write-Host "Installing PowerShell module: $module" - Install-Module $module -Scope CurrentUser -Force - } else { - Write-Host "PowerShell module $module is already installed." - } -} - -# Step 2: Login to Azure and set EEID tenant/subscription -Write-Host "Logging into Azure..." -$azLoggedIn = az account show 2>$null -if (-not $azLoggedIn) { - az login --tenant $TenantId - Write-Host "Logged in to Azure tenant $TenantId." -} else { - Write-Host "Already logged in to Azure." -} - -Write-Host "Setting Azure subscription..." -$currentSub = az account show --query id -o tsv -if ($currentSub -ne $SubscriptionId) { - az account set --subscription $SubscriptionId - Write-Host "Azure subscription set to $SubscriptionId." -} else { - Write-Host "Azure subscription already set to $SubscriptionId." -} - -# Step 3: Check for API app registration by name; create if missing -Write-Host "Checking for API app registration: $ApiAppRegistrationName..." -$apiApp = az ad app list --display-name $ApiAppRegistrationName --query "[0]" -o json | ConvertFrom-Json -if (-not $apiApp) { - Write-Host "API app registration not found. Creating..." - $apiApp = az ad app create ` - --display-name $ApiAppRegistrationName ` - --sign-in-audience AzureADMyOrg ` - --identifier-uris "api://$ApiAppRegistrationName" ` - --required-resource-access '[{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' ` - --api-access-token-version 2 ` - --query "appId" -o tsv - $apiAppId = $apiApp - Write-Host "Created API app registration with appId: $apiAppId" - # Add permission scopes and capture their IDs - $scopeIdMap = @{} - $scopes = @( - @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to view asset data."; userConsentDisplayName="Read your assets"; userConsentDescription="Allows the app to view your assets."}, - @{name="assets.write"; adminConsentDisplayName="Edit assets"; adminConsentDescription="Allows the app to create or update asset data."; userConsentDisplayName="Edit your assets"; userConsentDescription="Allows the app to create or update your assets."}, - @{name="assets.delete"; adminConsentDisplayName="Delete assets"; adminConsentDescription="Allows the app to delete asset data."; userConsentDisplayName="Delete your assets"; userConsentDescription="Allows the app to delete your assets."} - ) - foreach ($scope in $scopes) { - $scopeResult = az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription --query "id" -o tsv - $scopeIdMap[$scope.name] = $scopeResult - } - # Add app roles - $roles = @( - @{value="AssetViewer"; displayName="Asset Viewer"; description="Can view assets only."}, - @{value="AssetEditor"; displayName="Asset Editor"; description="Can view and edit assets."}, - @{value="AssetAdmin"; displayName="Asset Admin"; description="Can view, edit, and delete assets."} - ) - foreach ($role in $roles) { - az ad app update --id $apiAppId --app-roles "[{\"allowedMemberTypes\":[\"User\"],\"description\":\"$($role.description)\",\"displayName\":\"$($role.displayName)\",\"isEnabled\":true,\"origin\":\"Application\",\"value\":\"$($role.value)\"}]" - } - # Query the app registration to get all scope IDs - $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json - $apiScopes = @{} - foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { - $apiScopes[$scope.value] = $scope.id - } -} else { - Write-Host "API app registration $ApiAppRegistrationName already exists." - $apiAppId = $apiApp.appId - $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json - $apiScopes = @{} - foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { - $apiScopes[$scope.value] = $scope.id - } -} - -# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets -Write-Host "Setting EntraExternalId values for $ApiProjectPath" -Push-Location $ApiProjectPath -dotnet user-secrets init -dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl -dotnet user-secrets set "EntraExternalId:TenantId" $TenantId -dotnet user-secrets set "EntraExternalId:ClientId" $apiApp.appId -dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" -Pop-Location - -# Step 5: Check for Web app registration by name; create if missing -Write-Host "Checking for Web app registration: $WebAppRegistrationName..." -$webApp = az ad app list --display-name $WebAppRegistrationName --query "[0]" -o json | ConvertFrom-Json -if (-not $webApp) { - Write-Host "Web app registration not found. Creating..." - $apiResourceAccess = @() - foreach ($scopeName in $apiScopes.Keys) { - $apiResourceAccess += @{ id = $apiScopes[$scopeName]; type = "Scope" } - } - $requiredResourceAccess = @( - @{ resourceAppId = $apiAppId; resourceAccess = $apiResourceAccess }, - @{ resourceAppId = "00000003-0000-0000-c000-000000000000"; resourceAccess = @( - @{ id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"; type = "Scope" }, - @{ id = "14dad69e-099b-42c9-810b-d002981feec1"; type = "Scope" }, - @{ id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type = "Scope" } - ) } - ) | ConvertTo-Json -Compress - $webApp = az ad app create --display-name $WebAppRegistrationName ` - --sign-in-audience AzureADMyOrg ` - --web-redirect-uris $WebRedirectUri ` - --web-logout-url $WebLogoutUri ` - --web-implicit-grant true false ` - --required-resource-access $requiredResourceAccess ` - --query "appId" -o tsv - $webAppId = $webApp - Write-Host "Created Web app registration with appId: $webAppId" - # Add app role - az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"admin"}]' - # Add optional claims for idToken - $claims = @("ctry","email","upn","ipaddr","family_name","given_name","preferred_username") - foreach ($claim in $claims) { - az ad app update --id $webAppId --optional-claims-id-token "[{\"name\":\"$claim\",\"essential\":false}]" - } - # Create client secret - $webSecret = az ad app credential reset --id $webAppId --display-name "$WebAppRegistrationName-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv - Write-Host "Created client secret for Web app registration." - # Set permissions for Web app to use API as downstream OBO - # (Pre-authorize Web app in API app registration) - $permissionIds = @() - foreach ($scopeName in $apiScopes.Keys) { - $permissionIds += $apiScopes[$scopeName] - } - $preAuthApps = @(@{ appId = $webAppId; permissionIds = $permissionIds }) | ConvertTo-Json -Compress - az ad app update --id $apiAppId --pre-authorized-applications $preAuthApps - Write-Host "Pre-authorized Web app in API app registration." -} else { - Write-Host "Web app registration $WebAppRegistrationName already exists." -} - -# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets -Write-Host "Setting EntraExternalId values for $WebProjectPath" -Push-Location $WebProjectPath -dotnet user-secrets init -dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl -dotnet user-secrets set "EntraExternalId:TenantId" $TenantId -dotnet user-secrets set "EntraExternalId:ClientId" $webApp.appId -dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" -dotnet user-secrets set "EntraExternalId:ClientSecret" $webSecret -Pop-Location diff --git a/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 b/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 index e69de29..773d454 100644 --- a/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 +++ b/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 @@ -0,0 +1,185 @@ +# EEID App Registration and Secret Setup Script +# Location: .azure/scripts/New-EeidAppRegistrations.ps1 +# Description: Idempotently installs prerequisites, creates/updates EEID app registrations, sets permissions, and configures dotnet user-secrets for Presentation.WebApi and Presentation.Blazor. +param( + [string]$EeIdInstanceUrl, + [string]$TenantId, + [string]$SubscriptionId, + [string]$WebAppRegistrationName = "web-semker-dev", + [string]$WebProjectPath = "../../src/Presentation.Blazor", + [string]$ApiAppRegistrationName = "api-semker-dev", + [string]$ApiProjectPath = "../../src/Presentation.WebApi", + [string]$DotNetVersion = "10", + [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", + [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" +) + +# Step 1: Install prerequisites (az cli, dotnet sdk, modules) +Write-Host "Checking prerequisites..." + +# Check and install Azure CLI +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Host "Azure CLI not found. Installing via winget..." + winget install --id Microsoft.AzureCLI -e --silent +} else { + Write-Host "Azure CLI is already installed." +} + +# Check and install .NET SDK +$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." +if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent +} else { + Write-Host ".NET SDK $DotNetVersion is already installed." +} + +# Check and install PowerShell modules +$modules = @("Az.Accounts", "Az.Resources") +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } else { + Write-Host "PowerShell module $module is already installed." + } +} + +# Step 2: Login to Azure and set EEID tenant/subscription +Write-Host "Logging into Azure..." +$azLoggedIn = az account show 2>$null +if (-not $azLoggedIn) { + az login --tenant $TenantId + Write-Host "Logged in to Azure tenant $TenantId." +} else { + Write-Host "Already logged in to Azure." +} + +Write-Host "Setting Azure subscription..." +$currentSub = az account show --query id -o tsv +if ($currentSub -ne $SubscriptionId) { + az account set --subscription $SubscriptionId + Write-Host "Azure subscription set to $SubscriptionId." +} else { + Write-Host "Azure subscription already set to $SubscriptionId." +} + +# Step 3: Check for API app registration by name; create if missing +Write-Host "Checking for API app registration: $ApiAppRegistrationName..." +$apiApp = az ad app list --display-name $ApiAppRegistrationName --query "[0]" -o json | ConvertFrom-Json +if (-not $apiApp) { + Write-Host "API app registration not found. Creating..." + $apiApp = az ad app create ` + --display-name $ApiAppRegistrationName ` + --sign-in-audience AzureADMyOrg ` + --identifier-uris "api://$ApiAppRegistrationName" ` + --required-resource-access '[{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' ` + --api-access-token-version 2 ` + --query "appId" -o tsv + $apiAppId = $apiApp + Write-Host "Created API app registration with appId: $apiAppId" + # Add permission scopes and capture their IDs + $scopeIdMap = @{} + $scopes = @( + @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to view asset data."; userConsentDisplayName="Read your assets"; userConsentDescription="Allows the app to view your assets."}, + @{name="assets.write"; adminConsentDisplayName="Edit assets"; adminConsentDescription="Allows the app to create or update asset data."; userConsentDisplayName="Edit your assets"; userConsentDescription="Allows the app to create or update your assets."}, + @{name="assets.delete"; adminConsentDisplayName="Delete assets"; adminConsentDescription="Allows the app to delete asset data."; userConsentDisplayName="Delete your assets"; userConsentDescription="Allows the app to delete your assets."} + ) + foreach ($scope in $scopes) { + $scopeResult = az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription --query "id" -o tsv + $scopeIdMap[$scope.name] = $scopeResult + } + # Add app roles + $roles = @( + @{value="AssetViewer"; displayName="Asset Viewer"; description="Can view assets only."}, + @{value="AssetEditor"; displayName="Asset Editor"; description="Can view and edit assets."}, + @{value="AssetAdmin"; displayName="Asset Admin"; description="Can view, edit, and delete assets."} + ) + foreach ($role in $roles) { + az ad app update --id $apiAppId --app-roles "[{\"allowedMemberTypes\":[\"User\"],\"description\":\"$($role.description)\",\"displayName\":\"$($role.displayName)\",\"isEnabled\":true,\"origin\":\"Application\",\"value\":\"$($role.value)\"}]" + } + # Query the app registration to get all scope IDs + $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json + $apiScopes = @{} + foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { + $apiScopes[$scope.value] = $scope.id + } +} else { + Write-Host "API app registration $ApiAppRegistrationName already exists." + $apiAppId = $apiApp.appId + $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json + $apiScopes = @{} + foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { + $apiScopes[$scope.value] = $scope.id + } +} + +# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets +Write-Host "Setting EntraExternalId values for $ApiProjectPath" +Push-Location $ApiProjectPath +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl +dotnet user-secrets set "EntraExternalId:TenantId" $TenantId +dotnet user-secrets set "EntraExternalId:ClientId" $apiApp.appId +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +Pop-Location + +# Step 5: Check for Web app registration by name; create if missing +Write-Host "Checking for Web app registration: $WebAppRegistrationName..." +$webApp = az ad app list --display-name $WebAppRegistrationName --query "[0]" -o json | ConvertFrom-Json +if (-not $webApp) { + Write-Host "Web app registration not found. Creating..." + $apiResourceAccess = @() + foreach ($scopeName in $apiScopes.Keys) { + $apiResourceAccess += @{ id = $apiScopes[$scopeName]; type = "Scope" } + } + $requiredResourceAccess = @( + @{ resourceAppId = $apiAppId; resourceAccess = $apiResourceAccess }, + @{ resourceAppId = "00000003-0000-0000-c000-000000000000"; resourceAccess = @( + @{ id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"; type = "Scope" }, + @{ id = "14dad69e-099b-42c9-810b-d002981feec1"; type = "Scope" }, + @{ id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type = "Scope" } + ) } + ) | ConvertTo-Json -Compress + $webApp = az ad app create --display-name $WebAppRegistrationName ` + --sign-in-audience AzureADMyOrg ` + --web-redirect-uris $WebRedirectUri ` + --web-logout-url $WebLogoutUri ` + --web-implicit-grant true false ` + --required-resource-access $requiredResourceAccess ` + --query "appId" -o tsv + $webAppId = $webApp + Write-Host "Created Web app registration with appId: $webAppId" + # Add app role + az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"admin"}]' + # Add optional claims for idToken + $claims = @("ctry","email","upn","ipaddr","family_name","given_name","preferred_username") + foreach ($claim in $claims) { + az ad app update --id $webAppId --optional-claims-id-token "[{\"name\":\"$claim\",\"essential\":false}]" + } + # Create client secret + $webSecret = az ad app credential reset --id $webAppId --display-name "$WebAppRegistrationName-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv + Write-Host "Created client secret for Web app registration." + # Set permissions for Web app to use API as downstream OBO + # (Pre-authorize Web app in API app registration) + $permissionIds = @() + foreach ($scopeName in $apiScopes.Keys) { + $permissionIds += $apiScopes[$scopeName] + } + $preAuthApps = @(@{ appId = $webAppId; permissionIds = $permissionIds }) | ConvertTo-Json -Compress + az ad app update --id $apiAppId --pre-authorized-applications $preAuthApps + Write-Host "Pre-authorized Web app in API app registration." +} else { + Write-Host "Web app registration $WebAppRegistrationName already exists." +} + +# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets +Write-Host "Setting EntraExternalId values for $WebProjectPath" +Push-Location $WebProjectPath +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl +dotnet user-secrets set "EntraExternalId:TenantId" $TenantId +dotnet user-secrets set "EntraExternalId:ClientId" $webApp.appId +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +dotnet user-secrets set "EntraExternalId:ClientSecret" $webSecret +Pop-Location From 92cf6b7643772d948c4dcc8d4f8e6cdf3ac8c221 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Fri, 30 Jan 2026 14:44:39 -0800 Subject: [PATCH 4/9] config moved to dotnet user-secrets --- src/Presentation.Blazor/appsettings.Development.json | 6 +++--- src/Presentation.Blazor/appsettings.Local.json | 12 ++++++------ src/Presentation.Blazor/appsettings.Production.json | 4 ++-- src/Presentation.WebApi/appsettings.Development.json | 4 ++-- src/Presentation.WebApi/appsettings.Production.json | 4 ++-- src/Presentation.WebApi/appsettings.local.json | 8 ++++---- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Presentation.Blazor/appsettings.Development.json b/src/Presentation.Blazor/appsettings.Development.json index 5254bc0..9dabaa1 100644 --- a/src/Presentation.Blazor/appsettings.Development.json +++ b/src/Presentation.Blazor/appsettings.Development.json @@ -6,10 +6,10 @@ } }, "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=9a7279d9-9800-43d2-b05f-155605768276;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=6a69e75a-cbaa-441f-ad5c-1df55479adbf" + "ConnectionString": "" }, "Clarity": { - "ProjectId": "v8s54axxyw" + "ProjectId": "" }, "AllowedHosts": "*", "BackendApi": { @@ -17,7 +17,7 @@ "ClientId": "" }, "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", + "Instance": "", "TenantId": "", "ClientId": "", "ValidateAuthority": true diff --git a/src/Presentation.Blazor/appsettings.Local.json b/src/Presentation.Blazor/appsettings.Local.json index 573d8ce..b749e51 100644 --- a/src/Presentation.Blazor/appsettings.Local.json +++ b/src/Presentation.Blazor/appsettings.Local.json @@ -6,20 +6,20 @@ } }, "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=9a7279d9-9800-43d2-b05f-155605768276;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=6a69e75a-cbaa-441f-ad5c-1df55479adbf" + "ConnectionString": "" }, "Clarity": { - "ProjectId": "v8s54axxyw" + "ProjectId": "" }, "AllowedHosts": "*", "BackendApi": { "BaseUrl": "https://localhost:6075", - "ClientId": "c0150160-f59e-4798-933a-a06fcf5193d6" + "ClientId": "" }, "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", - "TenantId": "19800ad2-82e2-4946-abd5-c87ba78a7224", - "ClientId": "ccc36192-7b76-4f3d-9965-27a3aacc76a5", + "Instance": "", + "TenantId": "", + "ClientId": "", "ValidateAuthority": true } } diff --git a/src/Presentation.Blazor/appsettings.Production.json b/src/Presentation.Blazor/appsettings.Production.json index 0c947ed..9dabaa1 100644 --- a/src/Presentation.Blazor/appsettings.Production.json +++ b/src/Presentation.Blazor/appsettings.Production.json @@ -9,7 +9,7 @@ "ConnectionString": "" }, "Clarity": { - "ProjectId": "v8s54axxyw" + "ProjectId": "" }, "AllowedHosts": "*", "BackendApi": { @@ -17,7 +17,7 @@ "ClientId": "" }, "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", + "Instance": "", "TenantId": "", "ClientId": "", "ValidateAuthority": true diff --git a/src/Presentation.WebApi/appsettings.Development.json b/src/Presentation.WebApi/appsettings.Development.json index d0d5b39..da5f89e 100644 --- a/src/Presentation.WebApi/appsettings.Development.json +++ b/src/Presentation.WebApi/appsettings.Development.json @@ -7,14 +7,14 @@ } }, "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=9a7279d9-9800-43d2-b05f-155605768276;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=6a69e75a-cbaa-441f-ad5c-1df55479adbf" + "ConnectionString": "" }, "ConnectionStrings": { "DefaultConnection": "" }, "AllowedHosts": "*", "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", + "Instance": "", "TenantId": "", "ClientId": "", "ValidateAuthority": true diff --git a/src/Presentation.WebApi/appsettings.Production.json b/src/Presentation.WebApi/appsettings.Production.json index d0d5b39..da5f89e 100644 --- a/src/Presentation.WebApi/appsettings.Production.json +++ b/src/Presentation.WebApi/appsettings.Production.json @@ -7,14 +7,14 @@ } }, "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=9a7279d9-9800-43d2-b05f-155605768276;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=6a69e75a-cbaa-441f-ad5c-1df55479adbf" + "ConnectionString": "" }, "ConnectionStrings": { "DefaultConnection": "" }, "AllowedHosts": "*", "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", + "Instance": "", "TenantId": "", "ClientId": "", "ValidateAuthority": true diff --git a/src/Presentation.WebApi/appsettings.local.json b/src/Presentation.WebApi/appsettings.local.json index ee55051..fec1ed8 100644 --- a/src/Presentation.WebApi/appsettings.local.json +++ b/src/Presentation.WebApi/appsettings.local.json @@ -7,16 +7,16 @@ } }, "ApplicationInsights": { - "ConnectionString": "InstrumentationKey=9a7279d9-9800-43d2-b05f-155605768276;IngestionEndpoint=https://westus2-2.in.applicationinsights.azure.com/;LiveEndpoint=https://westus2.livediagnostics.monitor.azure.com/;ApplicationId=6a69e75a-cbaa-441f-ad5c-1df55479adbf" + "ConnectionString": "" }, "ConnectionStrings": { "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=DigitalInsights;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" }, "AllowedHosts": "*", "EntraExternalId": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", - "TenantId": "19800ad2-82e2-4946-abd5-c87ba78a7224", - "ClientId": "c0150160-f59e-4798-933a-a06fcf5193d6", + "Instance": "", + "TenantId": "", + "ClientId": "", "ValidateAuthority": true }, "OpenAI": { From dce51efdf682bdeab8d98846dec403b965538bf8 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 00:41:51 -0800 Subject: [PATCH 5/9] helper entra script --- .../New-EeidAppRegistrations.ps1 | 185 -------- .../entra/New-EntraAppRegistrationSecret.ps1 | 97 ++++ .../entra/New-EntraAppRegistrations.ps1 | 445 ++++++++++++++++++ .../scripts/entra/Set-ApiAppUserSecrets.ps1 | 62 +++ .../scripts/entra/Set-WebAppUserSecrets.ps1 | 68 +++ .../New-Github-Azure-Federation.ps1 | 0 .../scripts/cd/Add-ManagedIdentityRole.ps1 | 25 - .../scripts/cd/Get-ManagedIdentityRole.ps1 | 29 -- .../cd/New-Github-Azure-Federation.ps1 | 70 +++ .../scripts/cd/Remove-ManagedIdentityRole.ps1 | 30 -- .github/scripts/cd/Remove-NugetPackage.ps1 | 58 --- .github/scripts/iac/Copy-Custompolicy.ps1 | 85 ---- .github/scripts/iac/Install-AzureCli.ps1 | 1 - .../scripts/iac/New-AzServicePrinciple.ps1 | 57 --- .github/scripts/iac/New-SelfSignedCert.ps1 | 88 ---- .github/scripts/iac/Remove-LandingZone.ps1 | 48 -- .github/scripts/iac/Set-AzKeyVaultPolicy.ps1 | 43 -- .github/scripts/repo/az-create-for-rbac.cmd | 19 - README.md | 38 ++ .../appsettings.Local.json | 2 +- .../appsettings.local.json | 2 +- 21 files changed, 782 insertions(+), 670 deletions(-) delete mode 100644 .azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 create mode 100644 .azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 create mode 100644 .azure/scripts/entra/New-EntraAppRegistrations.ps1 create mode 100644 .azure/scripts/entra/Set-ApiAppUserSecrets.ps1 create mode 100644 .azure/scripts/entra/Set-WebAppUserSecrets.ps1 delete mode 100644 .azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 delete mode 100644 .github/scripts/cd/Add-ManagedIdentityRole.ps1 delete mode 100644 .github/scripts/cd/Get-ManagedIdentityRole.ps1 create mode 100644 .github/scripts/cd/New-Github-Azure-Federation.ps1 delete mode 100644 .github/scripts/cd/Remove-ManagedIdentityRole.ps1 delete mode 100644 .github/scripts/cd/Remove-NugetPackage.ps1 delete mode 100644 .github/scripts/iac/Copy-Custompolicy.ps1 delete mode 100644 .github/scripts/iac/Install-AzureCli.ps1 delete mode 100644 .github/scripts/iac/New-AzServicePrinciple.ps1 delete mode 100644 .github/scripts/iac/New-SelfSignedCert.ps1 delete mode 100644 .github/scripts/iac/Remove-LandingZone.ps1 delete mode 100644 .github/scripts/iac/Set-AzKeyVaultPolicy.ps1 delete mode 100644 .github/scripts/repo/az-create-for-rbac.cmd diff --git a/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 b/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 deleted file mode 100644 index 773d454..0000000 --- a/.azure/scripts/entra-external-id/New-EeidAppRegistrations.ps1 +++ /dev/null @@ -1,185 +0,0 @@ -# EEID App Registration and Secret Setup Script -# Location: .azure/scripts/New-EeidAppRegistrations.ps1 -# Description: Idempotently installs prerequisites, creates/updates EEID app registrations, sets permissions, and configures dotnet user-secrets for Presentation.WebApi and Presentation.Blazor. -param( - [string]$EeIdInstanceUrl, - [string]$TenantId, - [string]$SubscriptionId, - [string]$WebAppRegistrationName = "web-semker-dev", - [string]$WebProjectPath = "../../src/Presentation.Blazor", - [string]$ApiAppRegistrationName = "api-semker-dev", - [string]$ApiProjectPath = "../../src/Presentation.WebApi", - [string]$DotNetVersion = "10", - [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", - [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" -) - -# Step 1: Install prerequisites (az cli, dotnet sdk, modules) -Write-Host "Checking prerequisites..." - -# Check and install Azure CLI -if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Host "Azure CLI not found. Installing via winget..." - winget install --id Microsoft.AzureCLI -e --silent -} else { - Write-Host "Azure CLI is already installed." -} - -# Check and install .NET SDK -$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." -if (-not $dotnetInstalled) { - Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." - winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent -} else { - Write-Host ".NET SDK $DotNetVersion is already installed." -} - -# Check and install PowerShell modules -$modules = @("Az.Accounts", "Az.Resources") -foreach ($module in $modules) { - if (-not (Get-Module -ListAvailable -Name $module)) { - Write-Host "Installing PowerShell module: $module" - Install-Module $module -Scope CurrentUser -Force - } else { - Write-Host "PowerShell module $module is already installed." - } -} - -# Step 2: Login to Azure and set EEID tenant/subscription -Write-Host "Logging into Azure..." -$azLoggedIn = az account show 2>$null -if (-not $azLoggedIn) { - az login --tenant $TenantId - Write-Host "Logged in to Azure tenant $TenantId." -} else { - Write-Host "Already logged in to Azure." -} - -Write-Host "Setting Azure subscription..." -$currentSub = az account show --query id -o tsv -if ($currentSub -ne $SubscriptionId) { - az account set --subscription $SubscriptionId - Write-Host "Azure subscription set to $SubscriptionId." -} else { - Write-Host "Azure subscription already set to $SubscriptionId." -} - -# Step 3: Check for API app registration by name; create if missing -Write-Host "Checking for API app registration: $ApiAppRegistrationName..." -$apiApp = az ad app list --display-name $ApiAppRegistrationName --query "[0]" -o json | ConvertFrom-Json -if (-not $apiApp) { - Write-Host "API app registration not found. Creating..." - $apiApp = az ad app create ` - --display-name $ApiAppRegistrationName ` - --sign-in-audience AzureADMyOrg ` - --identifier-uris "api://$ApiAppRegistrationName" ` - --required-resource-access '[{"resourceAppId":"00000003-0000-0000-c000-000000000000","resourceAccess":[{"id":"e1fe6dd8-ba31-4d61-89e7-88639da4683d","type":"Scope"}]}]' ` - --api-access-token-version 2 ` - --query "appId" -o tsv - $apiAppId = $apiApp - Write-Host "Created API app registration with appId: $apiAppId" - # Add permission scopes and capture their IDs - $scopeIdMap = @{} - $scopes = @( - @{name="assets.read"; adminConsentDisplayName="Read assets"; adminConsentDescription="Allows the app to view asset data."; userConsentDisplayName="Read your assets"; userConsentDescription="Allows the app to view your assets."}, - @{name="assets.write"; adminConsentDisplayName="Edit assets"; adminConsentDescription="Allows the app to create or update asset data."; userConsentDisplayName="Edit your assets"; userConsentDescription="Allows the app to create or update your assets."}, - @{name="assets.delete"; adminConsentDisplayName="Delete assets"; adminConsentDescription="Allows the app to delete asset data."; userConsentDisplayName="Delete your assets"; userConsentDescription="Allows the app to delete your assets."} - ) - foreach ($scope in $scopes) { - $scopeResult = az ad app permission add --id $apiAppId --api $apiAppId --scope $scope.name --admin-consent-display-name $scope.adminConsentDisplayName --admin-consent-description $scope.adminConsentDescription --user-consent-display-name $scope.userConsentDisplayName --user-consent-description $scope.userConsentDescription --query "id" -o tsv - $scopeIdMap[$scope.name] = $scopeResult - } - # Add app roles - $roles = @( - @{value="AssetViewer"; displayName="Asset Viewer"; description="Can view assets only."}, - @{value="AssetEditor"; displayName="Asset Editor"; description="Can view and edit assets."}, - @{value="AssetAdmin"; displayName="Asset Admin"; description="Can view, edit, and delete assets."} - ) - foreach ($role in $roles) { - az ad app update --id $apiAppId --app-roles "[{\"allowedMemberTypes\":[\"User\"],\"description\":\"$($role.description)\",\"displayName\":\"$($role.displayName)\",\"isEnabled\":true,\"origin\":\"Application\",\"value\":\"$($role.value)\"}]" - } - # Query the app registration to get all scope IDs - $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json - $apiScopes = @{} - foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { - $apiScopes[$scope.value] = $scope.id - } -} else { - Write-Host "API app registration $ApiAppRegistrationName already exists." - $apiAppId = $apiApp.appId - $apiAppObj = az ad app show --id $apiAppId | ConvertFrom-Json - $apiScopes = @{} - foreach ($scope in $apiAppObj.api.oauth2PermissionScopes) { - $apiScopes[$scope.value] = $scope.id - } -} - -# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets -Write-Host "Setting EntraExternalId values for $ApiProjectPath" -Push-Location $ApiProjectPath -dotnet user-secrets init -dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl -dotnet user-secrets set "EntraExternalId:TenantId" $TenantId -dotnet user-secrets set "EntraExternalId:ClientId" $apiApp.appId -dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" -Pop-Location - -# Step 5: Check for Web app registration by name; create if missing -Write-Host "Checking for Web app registration: $WebAppRegistrationName..." -$webApp = az ad app list --display-name $WebAppRegistrationName --query "[0]" -o json | ConvertFrom-Json -if (-not $webApp) { - Write-Host "Web app registration not found. Creating..." - $apiResourceAccess = @() - foreach ($scopeName in $apiScopes.Keys) { - $apiResourceAccess += @{ id = $apiScopes[$scopeName]; type = "Scope" } - } - $requiredResourceAccess = @( - @{ resourceAppId = $apiAppId; resourceAccess = $apiResourceAccess }, - @{ resourceAppId = "00000003-0000-0000-c000-000000000000"; resourceAccess = @( - @{ id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"; type = "Scope" }, - @{ id = "14dad69e-099b-42c9-810b-d002981feec1"; type = "Scope" }, - @{ id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; type = "Scope" } - ) } - ) | ConvertTo-Json -Compress - $webApp = az ad app create --display-name $WebAppRegistrationName ` - --sign-in-audience AzureADMyOrg ` - --web-redirect-uris $WebRedirectUri ` - --web-logout-url $WebLogoutUri ` - --web-implicit-grant true false ` - --required-resource-access $requiredResourceAccess ` - --query "appId" -o tsv - $webAppId = $webApp - Write-Host "Created Web app registration with appId: $webAppId" - # Add app role - az ad app update --id $webAppId --app-roles '[{"allowedMemberTypes":["User"],"description":"Admins have the ability to alter root setups that affect all tenants","displayName":"Multi-tenant Admins","isEnabled":true,"origin":"Application","value":"admin"}]' - # Add optional claims for idToken - $claims = @("ctry","email","upn","ipaddr","family_name","given_name","preferred_username") - foreach ($claim in $claims) { - az ad app update --id $webAppId --optional-claims-id-token "[{\"name\":\"$claim\",\"essential\":false}]" - } - # Create client secret - $webSecret = az ad app credential reset --id $webAppId --display-name "$WebAppRegistrationName-$(Get-Date -Format yyyy)" --years 2 --query "secretText" -o tsv - Write-Host "Created client secret for Web app registration." - # Set permissions for Web app to use API as downstream OBO - # (Pre-authorize Web app in API app registration) - $permissionIds = @() - foreach ($scopeName in $apiScopes.Keys) { - $permissionIds += $apiScopes[$scopeName] - } - $preAuthApps = @(@{ appId = $webAppId; permissionIds = $permissionIds }) | ConvertTo-Json -Compress - az ad app update --id $apiAppId --pre-authorized-applications $preAuthApps - Write-Host "Pre-authorized Web app in API app registration." -} else { - Write-Host "Web app registration $WebAppRegistrationName already exists." -} - -# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets -Write-Host "Setting EntraExternalId values for $WebProjectPath" -Push-Location $WebProjectPath -dotnet user-secrets init -dotnet user-secrets set "EntraExternalId:Instance" $EeIdInstanceUrl -dotnet user-secrets set "EntraExternalId:TenantId" $TenantId -dotnet user-secrets set "EntraExternalId:ClientId" $webApp.appId -dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" -dotnet user-secrets set "EntraExternalId:ClientSecret" $webSecret -Pop-Location diff --git a/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 b/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 new file mode 100644 index 0000000..70abc1b --- /dev/null +++ b/.azure/scripts/entra/New-EntraAppRegistrationSecret.ps1 @@ -0,0 +1,97 @@ + +# ============================================================================ +# Script Name: New-EntraAppRegistrationSecret.ps1 +# Description: Creates a new client secret for an existing Entra External ID App Registration and sets it in dotnet user-secrets for a project. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./New-EntraAppRegistrationSecret.ps1 -TenantId "" -AppRegistrationName "" -DotnetProjectPath "" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires PowerShell modules: Az.Accounts, Microsoft.Graph.Applications +# - Ensure you are authenticated: Connect-AzAccount and Connect-MgGraph +# ============================================================================ +param( + [string]$TenantId, + [string]$AppRegistrationName, + [string]$DotnetProjectPath, + [int]$SecretMonths = 24, + [string]$DotNetVersion = "10" +) + +# Step 1: Install prerequisites (dotnet sdk, modules) +Write-Host "Checking prerequisites..." + +# Check and install .NET SDK +$dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." +if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent +} else { + Write-Host ".NET SDK $DotNetVersion is already installed." +} + +# Check and install PowerShell modules +$modules = @("Az.Accounts", "Az.Resources", "Microsoft.Graph.Applications") +foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } else { + Write-Host "PowerShell module $module is already installed." + } +} + +# Step 2: Login to Azure and set EEID tenant +Write-Host "Checking Azure authentication..." +$azContext = Get-AzContext -ErrorAction SilentlyContinue +if ($azContext -and $azContext.Tenant.Id -eq $TenantId) { + Write-Host "Already authenticated to Azure tenant $TenantId." +} else { + Write-Host "Logging into Azure..." + Connect-AzAccount -Tenant $TenantId | Out-Null + Write-Host "Logged in to Azure tenant $TenantId." +} + +# Check for Microsoft Graph authentication +$mgContext = $null +try { + $mgContext = Get-MgContext -ErrorAction Stop +} catch {} +if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Write-Host "Connecting to Microsoft Graph..." + Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All","Directory.ReadWrite.All" | Out-Null + $mgContext = Get-MgContext -ErrorAction Stop +} + +# Find the app registration +$app = Get-MgApplication -Filter "displayName eq '$AppRegistrationName'" -ErrorAction SilentlyContinue +if (-not $app) { + Write-Error "FATAL: App registration '$AppRegistrationName' not found. Cannot create secret." + exit 1 +} + +# Create new client secret + +# Create password credential object for secret expiration +$endDate = (Get-Date).AddMonths($SecretMonths) +$passwordCredential = @{ EndDateTime = $endDate } +$secretObj = Add-MgApplicationPassword -ApplicationId $app.Id -PasswordCredential $passwordCredential +if (-not $secretObj -or -not $secretObj.SecretText) { + Write-Error "FATAL: Failed to create client secret." + exit 1 +} +$secret = $secretObj.SecretText +Write-Host "Created new client secret for app registration '$AppRegistrationName'." + +# Set secret in dotnet user-secrets if project path exists +if (Test-Path $DotnetProjectPath) { + Write-Host "Setting EntraExternalId:ClientSecret in dotnet user-secrets for $DotnetProjectPath" + Push-Location $DotnetProjectPath + dotnet user-secrets init + dotnet user-secrets set "EntraExternalId:ClientSecret" $secret + Pop-Location + Write-Host "Secret set in dotnet user-secrets." +} else { + Write-Warning "*** CRITICAL: Project path '$DotnetProjectPath' not found. Skipping dotnet user-secrets. Secret was created in Entra ID. ***" +} + diff --git a/.azure/scripts/entra/New-EntraAppRegistrations.ps1 b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 new file mode 100644 index 0000000..3f02d4a --- /dev/null +++ b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 @@ -0,0 +1,445 @@ +# ============================================================================ +# Script Name: New-EntraAppRegistrations.ps1 +# Description: Creates new Entra External ID App Registrations. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./New-EntraAppRegistrations.ps1 ` +# -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" ` +# -TenantId "" ` +# -WebAppRegistrationName "myproduct-web-dev-001" ` +# -ApiAppRegistrationName "myproduct-api-dev-001" ` +# -WebProjectPath "../../src/Presentation.Blazor" ` +# -ApiProjectPath "../../src/Presentation.WebApi" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Az.Resources, etc.) +# - Ensure you are authenticated: Connect-AzAccount +# ============================================================================ +param( + [string]$EntraInstanceUrl, + [string]$TenantId, + [string]$WebAppRegistrationName, + [string]$ApiAppRegistrationName, + [string]$WebProjectPath, + [string]$ApiProjectPath, + [string]$DotNetVersion = "10", + [string]$WebRedirectUri = "https://localhost:7175/signin-oidc", + [string]$WebLogoutUri = "https://localhost:7175/signout-callback-oidc" +) + +function New-ApiRegistration { + param( + [string]$ApiAppRegistrationName, + [string]$TenantId + ) + Write-Host "Checking for API app registration: $ApiAppRegistrationName..." + $apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction SilentlyContinue + $created = $false + if (-not $apiApp) { + Write-Host "API app registration not found. Creating..." + try { + $apiApp = New-MgApplication -DisplayName $ApiAppRegistrationName -SignInAudience AzureADMyOrg + $created = $true + } + catch { + Write-Error "FATAL: Failed to create API app registration. $_.Exception.Message" + exit 1 + } + if (-not $apiApp -or -not $apiApp.AppId) { + Write-Error "FATAL: API app registration was not created. Exiting script." + exit 1 + } + $apiAppId = $apiApp.AppId + Write-Host "Created API app registration with appId: $apiAppId" + $identifierUri = "api://$apiAppId" + Update-MgApplication -ApplicationId $apiApp.Id -IdentifierUris @($identifierUri) + Write-Host "Set IdentifierUri to $identifierUri" + $customScopes = @( + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Read assets"; AdminConsentDescription = "Allows the app to view asset data."; UserConsentDisplayName = "Read your assets"; UserConsentDescription = "Allows the app to view your assets."; IsEnabled = $true; Type = "User"; Value = "assets.read" }, + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Edit assets"; AdminConsentDescription = "Allows the app to create or update asset data."; UserConsentDisplayName = "Edit your assets"; UserConsentDescription = "Allows the app to create or update your assets."; IsEnabled = $true; Type = "User"; Value = "assets.write" }, + @{ Id = [guid]::NewGuid(); AdminConsentDisplayName = "Delete assets"; AdminConsentDescription = "Allows the app to delete asset data."; UserConsentDisplayName = "Delete your assets"; UserConsentDescription = "Allows the app to delete your assets."; IsEnabled = $true; Type = "User"; Value = "assets.delete" } + ) + Update-MgApplication -ApplicationId $apiApp.Id -Api @{ OAuth2PermissionScopes = $customScopes } + Write-Host "Added custom OAuth2 permission scopes to API app registration." + $appRoles = @( + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view assets only."; DisplayName = "Asset Viewer"; IsEnabled = $true; Origin = "Application"; Value = "AssetViewer" }, + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view and edit assets."; DisplayName = "Asset Editor"; IsEnabled = $true; Origin = "Application"; Value = "AssetEditor" }, + @{ Id = [guid]::NewGuid(); AllowedMemberTypes = @("User"); Description = "Can view, edit, and delete assets."; DisplayName = "Asset Admin"; IsEnabled = $true; Origin = "Application"; Value = "AssetAdmin" } + ) + Update-MgApplication -ApplicationId $apiApp.Id -AppRoles $appRoles + Write-Host "Added app roles to API app registration." + $apiApp = Wait-ForApplicationPropagation -AppId $apiApp.AppId -ObjectId $apiApp.Id + } + else { + Write-Host "API app registration $ApiAppRegistrationName already exists." + } + + Write-Host "Adding Microsoft Graph User.Read permission to API app registration..." + $msGraphSp = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop + if (-not $msGraphSp) { + Write-Error "FATAL: Microsoft Graph service principal not found. Exiting script." + exit 1 + } + $userReadPerm = $msGraphSp.Oauth2PermissionScopes | Where-Object { $_.Value -eq "User.Read" } + if (-not $userReadPerm) { + Write-Error "FATAL: Microsoft Graph User.Read permission not found. Exiting script." + exit 1 + } + $apiAppReqPerms = @{ + ResourceAppId = $msGraphSp.AppId + ResourceAccess = @(@{ Id = $userReadPerm.Id; Type = "Scope" }) + } + Update-MgApplication -ApplicationId $apiApp.Id -RequiredResourceAccess @($apiAppReqPerms) + Write-Host "Added Microsoft Graph User.Read delegated permission to API app registration." + + $apiApp = Wait-ForApplicationPropagation -AppId $apiApp.AppId + $apiSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $apiApp.AppId } | Select-Object -First 1 + if (-not $apiSp) { + Write-Host "API service principal not found. Creating service principal for API app..." + $apiSp = New-MgServicePrincipal -AppId $apiApp.AppId + Start-Sleep -Seconds 5 + } + if ($apiSp) { + $portalConsentUrl = "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId/$($apiApp.AppId)/isMSAApp~/false" + Write-Host "" + Write-Host "ACTION REQUIRED: Grant admin consent for API app permissions in the Azure Portal:" -ForegroundColor Yellow + Write-Host "Open the following URL in your browser:" -ForegroundColor Yellow + Write-Host $portalConsentUrl -ForegroundColor Cyan + Write-Host "Then click 'Grant admin consent for ...' in the API permissions blade." -ForegroundColor Yellow + Write-Host "" + } + + return [PSCustomObject]@{ + App = $apiApp + AppId = $apiApp.AppId + ObjectId = $apiApp.Id + SpObjectId = if ($apiSp) { $apiSp.Id } else { "" } + Created = $created + } +} + +function New-WebRegistration { + param( + [string]$WebAppRegistrationName, + [string]$TenantId, + [string]$WebRedirectUri, + [string]$WebLogoutUri + ) + Write-Host "Checking for Web app registration: $WebAppRegistrationName..." + $webApp = Get-MgApplication -Filter "displayName eq '$WebAppRegistrationName'" -ErrorAction SilentlyContinue + $created = $false + $webSecret = $null + if (-not $webApp) { + Write-Host "Web app registration not found. Creating..." + try { + $webApp = New-MgApplication -DisplayName $WebAppRegistrationName -SignInAudience AzureADMyOrg -Web @{ RedirectUris = @($WebRedirectUri); LogoutUrl = $WebLogoutUri } + $created = $true + } + catch { + Write-Error "FATAL: Failed to create Web app registration. $_.Exception.Message" + exit 1 + } + if (-not $webApp -or -not $webApp.AppId) { + Write-Error "FATAL: Web app registration was not created. Exiting script." + exit 1 + } + Write-Host "Created Web app registration with appId: $($webApp.AppId)" + + Update-MgApplication -ApplicationId $webApp.Id -Web @{ ImplicitGrantSettings = @{ EnableIdTokenIssuance = $false; EnableAccessTokenIssuance = $true } } + Write-Host "Enabled access token issuance for implicit/hybrid flows in Web app registration." + + $webAppRoles = @( + @{ + Id = [Guid]::Parse("4c335757-d75a-4d79-8efc-ddcded89450b") + AllowedMemberTypes = @("User") + Description = "Admins have the ability to alter root setups that affect all tenants" + DisplayName = "Multi-tenant Admins" + IsEnabled = $true + Origin = "Application" + Value = "Asset.Admin" + } + ) + Update-MgApplication -ApplicationId $webApp.Id -AppRoles $webAppRoles + Write-Host "Added Asset.Admin app role to Web app registration." + + try { + $passwordCredential = @{ EndDateTime = (Get-Date).AddYears(2) } + $webSecretObj = Add-MgApplicationPassword -ApplicationId $webApp.Id -PasswordCredential $passwordCredential + } + catch { + Write-Error "FATAL: Failed to create client secret for Web app registration. $_.Exception.Message" + exit 1 + } + if (-not $webSecretObj -or -not $webSecretObj.SecretText) { + Write-Error "FATAL: Web app client secret was not created. Exiting script." + exit 1 + } + $webSecret = $webSecretObj.SecretText + Write-Host "Created client secret for Web app registration." + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId -ObjectId $webApp.Id + } + else { + Write-Host "Web app registration $WebAppRegistrationName already exists." + } + Write-Host "Adding Microsoft Graph User.Read, email, profile delegated permissions to Web app registration..." + $msGraphSp = Get-MgServicePrincipal -Filter "displayName eq 'Microsoft Graph'" -ErrorAction Stop + if (-not $msGraphSp) { + Write-Error "FATAL: Microsoft Graph service principal not found. Exiting script." + exit 1 + } + $delegatedPerms = @("User.Read", "email", "profile") + $permScopes = @() + foreach ($perm in $delegatedPerms) { + $scope = $msGraphSp.Oauth2PermissionScopes | Where-Object { $_.Value -eq $perm } + if (-not $scope) { + Write-Error "FATAL: Microsoft Graph permission $perm not found. Exiting script." + exit 1 + } + $permScopes += @{ Id = $scope.Id; Type = "Scope" } + } + $webAppReqPerms = @() + $webAppReqPerms += @{ + ResourceAppId = $msGraphSp.AppId + ResourceAccess = $permScopes + } + $apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction Stop + if (-not $apiApp) { + Write-Error "FATAL: API app registration $ApiAppRegistrationName not found. Exiting script." + exit 1 + } + $customScopes = @("assets.read", "assets.write", "assets.delete") + $apiScopes = @() + foreach ($scope in $customScopes) { + $apiScope = $apiApp.Api.Oauth2PermissionScopes | Where-Object { $_.Value -eq $scope } + if (-not $apiScope) { + Write-Error "FATAL: API scope $scope not found in API app registration. Exiting script." + exit 1 + } + $apiScopes += @{ Id = $apiScope.Id; Type = "Scope" } + } + $webAppReqPerms += @{ + ResourceAppId = $apiApp.AppId + ResourceAccess = $apiScopes + } + Update-MgApplication -ApplicationId $webApp.Id -RequiredResourceAccess $webAppReqPerms + Write-Host "Added Microsoft Graph User.Read, email, profile delegated permissions to Web app registration." + + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId -ObjectId $webApp.Id + $webSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $webApp.AppId } | Select-Object -First 1 + if (-not $webSp) { + Write-Host "Web service principal not found. Creating service principal for Web app..." + $webSp = New-MgServicePrincipal -AppId $webApp.AppId + Start-Sleep -Seconds 5 + } + if ($webSp) { + $portalConsentUrl = "https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId/$($webApp.AppId)/isMSAApp~/false" + Write-Host "" + Write-Host "ACTION REQUIRED: Grant admin consent for Web app permissions in the Azure Portal:" -ForegroundColor Yellow + Write-Host "Open the following URL in your browser:" -ForegroundColor Yellow + Write-Host $portalConsentUrl -ForegroundColor Cyan + Write-Host "Then click 'Grant admin consent for ...' in the API permissions blade." -ForegroundColor Yellow + Write-Host "" + } + + $optionalClaims = @{ + idToken = @( + @{ name = "ctry" }, + @{ name = "email" }, + @{ name = "family_name" }, + @{ name = "given_name" }, + @{ name = "ipaddr" }, + @{ name = "preferred_username" }, + @{ name = "upn" } + ) + } + Update-MgApplication -ApplicationId $webApp.Id -OptionalClaims $optionalClaims + Write-Host "Added optional claims (ctry, email, family_name, given_name, ipaddr, preferred_username, upn) to Web app registration." + + $webApp = Wait-ForApplicationPropagation -AppId $webApp.AppId + $webSp = Get-MgServicePrincipal -All | Where-Object { $_.AppId -eq $webApp.AppId } | Select-Object -First 1 + return [PSCustomObject]@{ + App = $webApp + AppId = $webApp.AppId + ObjectId = $webApp.Id + SpObjectId = if ($webSp) { $webSp.Id } else { "" } + Created = $created + Secret = $webSecret + } +} + +function New-Auth { + param( + [string]$TenantId + ) + function New-AzLogin { + param([string]$TenantId) + Write-Host "Checking Azure authentication..." + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if ($azContext -and $azContext.Tenant.Id -eq $TenantId) { + Write-Host "Already authenticated to Azure tenant $TenantId." + } + else { + Write-Host "Logging into Azure..." + Connect-AzAccount -Tenant $TenantId | Out-Null + Write-Host "Logged in to Azure tenant $TenantId." + } + } + function New-MgLogin { + param([string]$TenantId) + $mgContext = $null + try { + $mgContext = Get-MgContext -ErrorAction Stop + } + catch {} + if ($mgContext -and $mgContext.TenantId -eq $TenantId -and $mgContext.Account) { + Write-Host "Already authenticated to Microsoft Graph for tenant $TenantId." + } + else { + try { + Write-Host "Connecting to Microsoft Graph..." + Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All", "Directory.ReadWrite.All" | Out-Null + $mgContext = Get-MgContext -ErrorAction Stop + Write-Host "Connected to Microsoft Graph for tenant $TenantId." + } + catch { + Write-Error "FATAL: Microsoft Graph authentication failed. Exiting script." + exit 1 + } + } + } + New-AzLogin -TenantId $TenantId + New-MgLogin -TenantId $TenantId +} + +function Install-Prerequisites { + param( + [string]$DotNetVersion + ) + Write-Host "Checking prerequisites..." + $dotnetInstalled = & dotnet --list-sdks | Select-String "^$DotNetVersion\." + if (-not $dotnetInstalled) { + Write-Host ".NET SDK $DotNetVersion not found. Installing via winget..." + winget install --id Microsoft.DotNet.SDK.$DotNetVersion -e --silent + } + else { + Write-Host ".NET SDK $DotNetVersion is already installed." + } + $modules = @("Az.Accounts", "Az.Resources", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + else { + Write-Host "PowerShell module $module is already installed." + } + } + # Import modules here so they're available everywhere + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Az.Resources -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function Set-ProjectUserSecrets { + param( + [string]$ProjectPath, + [hashtable]$Secrets + ) + if (Test-Path $ProjectPath) { + Write-Host "Setting EntraExternalId values for $ProjectPath" + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + } + else { + Write-Warning "*** CRITICAL: Project path '$ProjectPath' not found. Skipping dotnet user-secrets. App registrations will continue, but user-secrets are NOT set. ***" + } +} + +function Wait-ForApplicationPropagation { + param( + [string]$AppId, + [string]$ObjectId = $null, + [int]$MaxRetries = 10, + [int]$DelaySeconds = 2 + ) + $retryCount = 0 + $app = $null + do { + Start-Sleep -Seconds $DelaySeconds + if ($ObjectId) { + $app = Get-MgApplication -ApplicationId $ObjectId -ErrorAction SilentlyContinue + } + if (-not $app -and $AppId) { + $app = Get-MgApplication -Filter "appId eq '$AppId'" -ErrorAction SilentlyContinue + } + $retryCount++ + } while (-not $app -and $retryCount -lt $MaxRetries) + if (-not $app) { + Write-Error "FATAL: Application $AppId was not found after creation and waiting. Exiting script." + exit 1 + } + return $app +} + +function Write-OutputSummary { + param( + [string]$TenantId, + [string]$EntraInstanceUrl, + [object]$ApiApp, + [object]$WebApp, + [string]$WebRedirectUri, + [string]$WebLogoutUri + ) + Write-Host "`n================= OUTPUT SUMMARY =================" + Write-Host "TenantId: $TenantId" + Write-Host "Instance: $EntraInstanceUrl" + Write-Host "API AppId: $($ApiApp.AppId)" + Write-Host "API ObjectId: $($ApiApp.ObjectId)" + Write-Host "API Service Principal ObjectId: $($ApiApp.SpObjectId)" + Write-Host "Web AppId: $($WebApp.AppId)" + Write-Host "Web ObjectId: $($WebApp.ObjectId)" + Write-Host "Web Service Principal ObjectId: $($WebApp.SpObjectId)" + Write-Host "Web Redirect URI: $WebRedirectUri" + Write-Host "Web Logout URI: $WebLogoutUri" + Write-Host "=================================================`n" +} + +# Step 1: Install prerequisites +Install-Prerequisites -DotNetVersion $DotNetVersion + +# Step 2: Authenticate to Azure and Microsoft Graph +New-Auth -TenantId $TenantId + +# Step 3: Create or get API app registration using function +$apiReg = New-ApiRegistration -ApiAppRegistrationName $ApiAppRegistrationName -TenantId $TenantId +$apiApp = $apiReg + +# Step 4: Write API EEID values to $ApiProjectPath via dotnet user-secrets +$apiSecrets = @{ + "EntraExternalId:Instance" = $EntraInstanceUrl + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $apiApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +Set-ProjectUserSecrets -ProjectPath $ApiProjectPath -Secrets $apiSecrets + +# Step 5: Create or get Web app registration using function and capture output +$webReg = New-WebRegistration -WebAppRegistrationName $WebAppRegistrationName -TenantId $TenantId -WebRedirectUri $WebRedirectUri -WebLogoutUri $WebLogoutUri +$webApp = $webReg + +# Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets +$webSecrets = @{ + "EntraExternalId:Instance" = $EntraInstanceUrl + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $webApp.AppId + "EntraExternalId:ValidateAuthority" = "true" + "EntraExternalId:ClientSecret" = $webApp.Secret +} +Set-ProjectUserSecrets -ProjectPath $WebProjectPath -Secrets $webSecrets + +# Step 7: Output summary of created app registrations +Write-OutputSummary -TenantId $TenantId -EntraInstanceUrl $EntraInstanceUrl -ApiApp $apiApp -WebApp $webApp -WebRedirectUri $WebRedirectUri -WebLogoutUri $WebLogoutUri \ No newline at end of file diff --git a/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 new file mode 100644 index 0000000..1c8f446 --- /dev/null +++ b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 @@ -0,0 +1,62 @@ +param( + [string]$TenantId, + [string]$ApiAppRegistrationName, + [string]$ApiProjectPath +) + +function Install-Prerequisites { + $modules = @("Az.Accounts", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + } + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function New-Auth { + param([string]$TenantId) + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if (-not $azContext -or $azContext.Tenant.Id -ne $TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + $mgContext = $null + try { $mgContext = Get-MgContext -ErrorAction Stop } catch {} + if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Connect-MgGraph -TenantId $TenantId -Scopes "Application.Read.All" | Out-Null + } +} + +function Set-ProjectUserSecrets { + param([string]$ProjectPath, [hashtable]$Secrets) + if (Test-Path $ProjectPath) { + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + Write-Host "Secrets set for $ProjectPath" + } else { + Write-Warning "*** Project path '$ProjectPath' not found. Skipping dotnet user-secrets. ***" + } +} + +Install-Prerequisites +New-Auth -TenantId $TenantId + +$apiApp = Get-MgApplication -Filter "displayName eq '$ApiAppRegistrationName'" -ErrorAction Stop +if (-not $apiApp) { + Write-Error "API app registration '$ApiAppRegistrationName' not found." + exit 1 +} + +$apiSecrets = @{ + "EntraExternalId:Instance" = "https://login.microsoftonline.com" + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $apiApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +Set-ProjectUserSecrets -ProjectPath $ApiProjectPath -Secrets $apiSecrets \ No newline at end of file diff --git a/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 new file mode 100644 index 0000000..41014e0 --- /dev/null +++ b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 @@ -0,0 +1,68 @@ +param( + [string]$TenantId, + [string]$WebAppRegistrationName, + [string]$WebProjectPath +) + +function Install-Prerequisites { + $modules = @("Az.Accounts", "Microsoft.Graph.Applications") + foreach ($module in $modules) { + if (-not (Get-Module -ListAvailable -Name $module)) { + Write-Host "Installing PowerShell module: $module" + Install-Module $module -Scope CurrentUser -Force + } + } + Import-Module Az.Accounts -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -ErrorAction Stop +} + +function New-Auth { + param([string]$TenantId) + $azContext = Get-AzContext -ErrorAction SilentlyContinue + if (-not $azContext -or $azContext.Tenant.Id -ne $TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + $mgContext = $null + try { $mgContext = Get-MgContext -ErrorAction Stop } catch {} + if (-not $mgContext -or $mgContext.TenantId -ne $TenantId -or -not $mgContext.Account) { + Connect-MgGraph -TenantId $TenantId -Scopes "Application.Read.All" | Out-Null + } +} + +function Set-ProjectUserSecrets { + param([string]$ProjectPath, [hashtable]$Secrets) + if (Test-Path $ProjectPath) { + Push-Location $ProjectPath + dotnet user-secrets init + foreach ($key in $Secrets.Keys) { + dotnet user-secrets set $key $Secrets[$key] + } + Pop-Location + Write-Host "Secrets set for $ProjectPath" + } else { + Write-Warning "*** Project path '$ProjectPath' not found. Skipping dotnet user-secrets. ***" + } +} + +Install-Prerequisites +New-Auth -TenantId $TenantId + +$webApp = Get-MgApplication -Filter "displayName eq '$WebAppRegistrationName'" -ErrorAction Stop +if (-not $webApp) { + Write-Error "Web app registration '$WebAppRegistrationName' not found." + exit 1 +} + +$secrets = Get-MgApplicationPassword -ApplicationId $webApp.Id +$clientSecret = $secrets | Select-Object -First 1 -ExpandProperty SecretText + +$webSecrets = @{ + "EntraExternalId:Instance" = "https://login.microsoftonline.com" + "EntraExternalId:TenantId" = $TenantId + "EntraExternalId:ClientId" = $webApp.AppId + "EntraExternalId:ValidateAuthority" = "true" +} +if ($clientSecret) { + $webSecrets["EntraExternalId:ClientSecret"] = $clientSecret +} +Set-ProjectUserSecrets -ProjectPath $WebProjectPath -Secrets $webSecrets \ No newline at end of file diff --git a/.azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 b/.azure/scripts/github-to-azure-federation/New-Github-Azure-Federation.ps1 deleted file mode 100644 index e69de29..0000000 diff --git a/.github/scripts/cd/Add-ManagedIdentityRole.ps1 b/.github/scripts/cd/Add-ManagedIdentityRole.ps1 deleted file mode 100644 index 0042cf8..0000000 --- a/.github/scripts/cd/Add-ManagedIdentityRole.ps1 +++ /dev/null @@ -1,25 +0,0 @@ -#----------------------------------------------------------------------- -# Add-RoleToManagedIdentity [ObjectId []] [ApplicationId []] [Permission []] -# -# Example: .\Add-RoleToManagedIdentity -ObjectId 00000000-0000-0000-0000-000000000000 -ApplicationId 00000000-0000-0000-0000-000000000000 -# -Permission User.Read.All -# -# The app IDs of the Microsoft APIs are the same in all tenants: -# Microsoft Graph: 00000003-0000-0000-c000-000000000000 -# SharePoint Online: 00000003-0000-0ff1-ce00-000000000000 -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -[Cmdletbinding()] -Param( - [Parameter(Mandatory = $true)][string]$ObjectId=$(throw 'ObjectId is a required parameter. (Your Managed Identity Object Id to get new roles)'), - [Parameter(Mandatory = $true)][string]$ApplicationId=$(throw 'ApplicationId is a required parameter. (The Application Id of the resource to access)'), - [Parameter(Mandatory = $true)][string]$Permission=$(throw 'Permission is a required parameter. (I.e. "User.Read.All", "User.Invite.All", "GroupMember.ReadWrite.All")') -) - -Connect-AzureAD -$app = Get-AzureADServicePrincipal -Filter "AppId eq '$ApplicationId'" -$role = $app.AppRoles | where Value -Like $Permission | Select-Object -First 1 -New-AzureADServiceAppRoleAssignment -Id $role.Id -ObjectId $ObjectId -PrincipalId $ObjectId -ResourceId $app.ObjectId diff --git a/.github/scripts/cd/Get-ManagedIdentityRole.ps1 b/.github/scripts/cd/Get-ManagedIdentityRole.ps1 deleted file mode 100644 index 6961d11..0000000 --- a/.github/scripts/cd/Get-ManagedIdentityRole.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -#----------------------------------------------------------------------- -# Get-ManagedIdentityRole [ObjectId []] [ApplicationId []] -# -# Example: .\Get-ManagedIdentityRole -ObjectId 00000000-0000-0000-0000-000000000000 -ApplicationId 00000000-0000-0000-0000-000000000000 -# -# The app IDs of the Microsoft APIs are the same in all tenants: -# Microsoft Graph: 00000003-0000-0000-c000-000000000000 -# SharePoint Online: 00000003-0000-0ff1-ce00-000000000000 -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -[Cmdletbinding()] -Param( - [Parameter(Mandatory = $true)][string]$ObjectId=$(throw 'ObjectId is a required parameter. (Your Managed Identity Object Id to get new roles)'), - [Parameter(Mandatory = $true)][string]$ApplicationId=$(throw 'ApplicationId is a required parameter. (The Application Id of the resource to access)') -) - -Connect-AzureAD - -$app = Get-AzureADServicePrincipal -Filter "AppId eq '$ApplicationId'" - -$appRoles = Get-AzureADServiceAppRoleAssignment -ObjectId $app.ObjectId | where PrincipalId -eq $ObjectId - -foreach ($appRole in $appRoles) { - $role = $app.AppRoles | where Id -eq $appRole.Id | Select-Object -First 1 - write-host $role.Value -} diff --git a/.github/scripts/cd/New-Github-Azure-Federation.ps1 b/.github/scripts/cd/New-Github-Azure-Federation.ps1 new file mode 100644 index 0000000..9965ce9 --- /dev/null +++ b/.github/scripts/cd/New-Github-Azure-Federation.ps1 @@ -0,0 +1,70 @@ +#################################################################################### +# To execute +# 1. Run Powershell as ADMINistrator +# 2. In powershell, set security polilcy for this script: +# Set-ExecutionPolicy Unrestricted -Scope Process -Force +# 3. Change directory to the script folder: +# CD C:\Scripts (wherever your script is) +# 4. In powershell, run script: +# .\New-AzureGitHubFederation.ps1 -SubscriptionId 12343dac-0e69-436a-866b-456727dd3579 -PrincipalName myco-github-devtest-001 -Organization mygithuborg -Repository mygithubrepo -Environment development +#################################################################################### + +param ( + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [guid]$SubscriptionId = $(throw '-SubscriptionId is a required parameter.'), #12343dac-0e69-436a-866b-456727dd3579 + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$PrincipalName = $(throw '-PrincipalName is a required parameter.'), #Example: COMPANY-SUB_OR_PRODUCTLINE-github-001 + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Organization = $(throw '-Organization is a required parameter.'), #GitHub Organization Name + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Repository = $(throw '-Repository is a required parameter.'), #GitHub Repository Name + [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] + [string]$Environment = $(throw '-Environment is a required parameter.') #GitHub Repository Environment: development, production + ) +#################################################################################### +Set-ExecutionPolicy Unrestricted -Scope Process -Force +$VerbosePreference = 'SilentlyContinue' # 'SilentlyContinue' # 'Continue' +[String]$ThisScript = $MyInvocation.MyCommand.Path +[String]$ThisDir = Split-Path $ThisScript +Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths +Write-Host "*****************************" +Write-Host "*** Starting: $ThisScript On: $(Get-Date)" +Write-Host "*****************************" +#################################################################################### +# Install required modules +Install-Module Az.Accounts,Az.Resources -Scope CurrentUser -Force + +# Login to Azure +Connect-AzAccount -SubscriptionId $SubscriptionId -UseDeviceAuthentication + +# Get App Registration object (Application object) +$app = Get-AzADApplication -DisplayName $PrincipalName +if (-not $app) { + $app = New-AzADApplication -DisplayName $PrincipalName +} +Write-Host "App Registration (Client) Id: $($app.AppId)" +$clientId = $app.AppId +$appObjectId = $app.Id + +# Create Service Principal and assign role +$sp = Get-AzADServicePrincipal -DisplayName $PrincipalName +if (-not $sp) { + $sp = New-AzADServicePrincipal -ApplicationId $clientId +} +Write-Host "Service Principal Id: $($sp.Id)" +$spObjectId = $sp.Id +New-AzRoleAssignment -ObjectId $spObjectId -RoleDefinitionName Contributor -Scope "/subscriptions/$SubscriptionId" + +$tenantId = (Get-AzContext).Subscription.TenantId + +# Create new App Registration Federated Credentials for the GitHub operations +$subjectRepo = "repo:" + $Organization + "/" + $Repository + ":environment:" + $Environment +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-repo" -Subject "$subjectRepo" +$subjectRepoMain = "repo:" + $Organization + "/" + $Repository + ":ref:refs/heads/main" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-main" -Subject "$subjectRepoMain" +$subjectRepoPR = "repo:" + $Organization + "/" + $Repository + ":pull_request" +New-AzADAppFederatedCredential -ApplicationObjectId $appObjectId -Audience api://AzureADTokenExchange -Issuer 'https://token.actions.githubusercontent.com' -Name "$PrincipalName-PR" -Subject "$subjectRepoPR" + +Write-Host "AZURE_TENANT_ID: $tenantId" +Write-Host "AZURE_SUBSCRIPTION_ID: $SubscriptionId" +Write-Host "AZURE_CLIENT_ID: $clientId" \ No newline at end of file diff --git a/.github/scripts/cd/Remove-ManagedIdentityRole.ps1 b/.github/scripts/cd/Remove-ManagedIdentityRole.ps1 deleted file mode 100644 index c71329b..0000000 --- a/.github/scripts/cd/Remove-ManagedIdentityRole.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -#----------------------------------------------------------------------- -# Remove-ManagedIdentityRole [ObjectId []] [ApplicationId []] [Permission []] -# -# Example: .\Remove-ManagedIdentityRole -ObjectId 00000000-0000-0000-0000-000000000000 -ApplicationId 00000000-0000-0000-0000-000000000000 -# -Permission User.Read.All -# -# The app IDs of the Microsoft APIs are the same in all tenants: -# Microsoft Graph: 00000003-0000-0000-c000-000000000000 -# SharePoint Online: 00000003-0000-0ff1-ce00-000000000000 -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -[Cmdletbinding()] -Param( - [Parameter(Mandatory = $true)][string]$ObjectId=$(throw 'ObjectId is a required parameter. (Your Managed Identity Object Id to get new roles)'), - [Parameter(Mandatory = $true)][string]$ApplicationId=$(throw 'ApplicationId is a required parameter. (The Application Id of the resource to access)'), - [Parameter(Mandatory = $true)][string]$Permission=$(throw 'Permission is a required parameter. (I.e. "User.Read.All", "User.Invite.All", "GroupMember.ReadWrite.All")') -) - -Connect-AzureAD -$app = Get-AzureADServicePrincipal -Filter "AppId eq '$ApplicationId'" -$appRoles = Get-AzureADServiceAppRoleAssignment -ObjectId $app.ObjectId | where PrincipalId -eq $ObjectId -foreach ($appRole in $appRoles) { - $role = $app.AppRoles | where Id -eq $appRole.Id | Select-Object -First 1 - if ($Permission.Contains($role.Value)) { - Remove-AzureADServiceAppRoleAssignment -ObjectId $app.ObjectId -AppRoleAssignmentId $appRole.ObjectId - } -} diff --git a/.github/scripts/cd/Remove-NugetPackage.ps1 b/.github/scripts/cd/Remove-NugetPackage.ps1 deleted file mode 100644 index ae94b21..0000000 --- a/.github/scripts/cd/Remove-NugetPackage.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -<# -.SYNOPSIS -Deletes all versions of a specified NuGet package from a NuGet feed. -.DESCRIPTION -This script queries the NuGet API for all versions of a given package and deletes each version using the dotnet CLI. Requires PowerShell 7+ and the dotnet CLI to be installed. You must provide a valid NuGet API key with permission to delete the package. -.PARAMETER PackageName -The name of the NuGet package to delete. -.PARAMETER ApiKey -The NuGet API key with permission to delete the package. -.PARAMETER Source -The NuGet source/feed URL. Defaults to https://api.nuget.org/v3/index.json -.EXAMPLE -pwsh ./Remove-NugetListing.ps1 -PackageName My.Package -ApiKey -.EXAMPLE -pwsh ./Remove-NugetListing.ps1 -PackageName My.Package -ApiKey -Source "https://my.custom.nuget/feed/v3/index.json" -#> - -param( - [Parameter(Mandatory)] - [string]$PackageName, - - [Parameter(Mandatory)] - [string]$ApiKey, - - [string]$Source = "https://api.nuget.org/v3/index.json" -) - -$ErrorActionPreference = 'Stop' - -# Build the flat container API URL -$flatApiUrl = "https://api.nuget.org/v3-flatcontainer/$($PackageName.ToLowerInvariant())/index.json" - -Write-Host "Querying all versions for package: $PackageName" -ForegroundColor Cyan - -try { - $response = Invoke-RestMethod -Uri $flatApiUrl - $versions = $response.versions -} catch { - Write-Error "Failed to query package versions. Ensure the package exists and the name is correct. $_" - exit 1 -} - -if (-not $versions) { - Write-Warning "No versions found for package $PackageName." - exit 0 -} - -foreach ($version in $versions) { - Write-Host "Deleting $PackageName $version from source $Source..." -ForegroundColor Yellow - try { - dotnet nuget delete $PackageName $version --source $Source --non-interactive --api-key $ApiKey --no-service-endpoint - Write-Host "Successfully deleted $PackageName $version" -ForegroundColor Green - } catch { - Write-Warning "Failed to delete $PackageName $version" - } -} - -Write-Host "Completed deleting all versions of $PackageName." -ForegroundColor Cyan \ No newline at end of file diff --git a/.github/scripts/iac/Copy-Custompolicy.ps1 b/.github/scripts/iac/Copy-Custompolicy.ps1 deleted file mode 100644 index 22d726e..0000000 --- a/.github/scripts/iac/Copy-Custompolicy.ps1 +++ /dev/null @@ -1,85 +0,0 @@ -#----------------------------------------------------------------------- -# Copy-CustomPolicy [ClientId []] [ClientSecret []] [TenantId []] -# [Folder []] [Files []] -# -# Example: .\Copy-CustomPolicy -ClientId 00000000-0000-0000-0000-000000000000 -ClientSecret xxxxx -TenantId 00000000-0000-0000-0000-000000000000 -# -Folder $(System.DefaultWorkingDirectory)/policy/B2CAssets/ -# -Files "TrustFrameworkBase.xml,TrustFrameworkLocalization.xml,TrustFrameworkExtensions.xml,SignUpOrSignin.xml,ProfileEdit.xml,PasswordReset.xml" -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -[Cmdletbinding()] -Param( - [Parameter(Mandatory = $true)][string]$ClientId, - [Parameter(Mandatory = $true)][string]$ClientSecret, - [Parameter(Mandatory = $true)][string]$TenantId, - [Parameter(Mandatory = $true)][string]$Folder, - [Parameter(Mandatory = $true)][string]$Files -) - -try { - $body = @{grant_type = "client_credentials"; scope = "https://graph.microsoft.com/.default"; client_id = $ClientId; client_secret = $ClientSecret } - - $response = Invoke-RestMethod -Uri https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token -Method Post -Body $body - $token = $response.access_token - - $headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]" - $headers.Add("Content-Type", 'application/xml') - $headers.Add("Authorization", 'Bearer ' + $token) - - # Get the list of files to upload - $filesArray = $Files.Split(",") - - Foreach ($file in $filesArray) { - - $filePath = $Folder + $file.Trim() - - # Check if file exists - $FileExists = Test-Path -Path $filePath -PathType Leaf - - if ($FileExists) { - $policycontent = Get-Content $filePath - - # Optional: Change the content of the policy. For example, replace the tenant-name with your tenant name. - # $policycontent = $policycontent.Replace("your-tenant.onmicrosoft.com", "contoso.onmicrosoft.com") - - - # Get the policy name from the XML document - $match = Select-String -InputObject $policycontent -Pattern '(?<=\bPolicyId=")[^"]*' - - If ($match.matches.groups.count -ge 1) { - $PolicyId = $match.matches.groups[0].value - - Write-Host "Uploading the" $PolicyId "policy..." - - $graphuri = 'https://graph.microsoft.com/beta/trustframework/policies/' + $PolicyId + '/$value' - $response = Invoke-RestMethod -Uri $graphuri -Method Put -Body $policycontent -Headers $headers - - Write-Host "Policy" $PolicyId "uploaded successfully." - } - } - else { - $warning = "File " + $filePath + " couldn't be not found." - Write-Warning -Message $warning - } - } -} -catch { - Write-Host "StatusCode:" $_.Exception.Response.StatusCode.value__ - - $_ - - $streamReader = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) - $streamReader.BaseStream.Position = 0 - $streamReader.DiscardBufferedData() - $errResp = $streamReader.ReadToEnd() - $streamReader.Close() - - $ErrResp - - exit 1 -} - -exit 0 \ No newline at end of file diff --git a/.github/scripts/iac/Install-AzureCli.ps1 b/.github/scripts/iac/Install-AzureCli.ps1 deleted file mode 100644 index 039eb74..0000000 --- a/.github/scripts/iac/Install-AzureCli.ps1 +++ /dev/null @@ -1 +0,0 @@ -Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile .\AzureCLI.msi; Start-Process msiexec.exe -Wait -ArgumentList '/I AzureCLI.msi /quiet' \ No newline at end of file diff --git a/.github/scripts/iac/New-AzServicePrinciple.ps1 b/.github/scripts/iac/New-AzServicePrinciple.ps1 deleted file mode 100644 index 35b1ce5..0000000 --- a/.github/scripts/iac/New-AzServicePrinciple.ps1 +++ /dev/null @@ -1,57 +0,0 @@ -#----------------------------------------------------------------------- -# New-AzServicePrinciple [-Name []] [-TenantId []] [-SubscriptionId []] -# -# Example: .\New-AzServicePrinciple -Name -TenantId -SubscriptionId -# CLI: az ad sp create-for-rbac --name "myApp" --role contributor \ -# --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} --sdk-auth -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -param -( - [string] $Name=$(throw '-Name is a required parameter. (myco-product-environment)'), - [string] $TenantId=$(throw '-TenantId is a required parameter. (00000000-0000-0000-0000-000000000000)'), - [string] $SubscriptionId=$(throw '-SubscriptionId is a required parameter. (00000000-0000-0000-0000-000000000000)') -) - -# *** -# *** Initialize -# *** -if ($IsWindows) { Set-ExecutionPolicy Unrestricted -Scope Process -Force } -$VerbosePreference = 'SilentlyContinue' #'Continue' -[String]$ThisScript = $MyInvocation.MyCommand.Path -[String]$ThisDir = Split-Path $ThisScript -[DateTime]$Now = Get-Date -Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths -Write-Host "*****************************" -Write-Host "*** Starting: $ThisScript on $Now" -Write-Host "*****************************" -# Imports -Import-Module "../System.psm1" -Install-Module -Name Az.Accounts -AllowClobber -Scope CurrentUser -Install-Module -Name Az.Resources -AllowClobber -Scope CurrentUser - -# *** -# *** Auth -# *** -Write-Host "*** Auth ***" -Connect-AzAccount -Tenant $TenantId -Subscription $SubscriptionId - -$sp = New-AzADServicePrincipal -DisplayName $Name -$clientsec = [System.Net.NetworkCredential]::new("", $sp.passwordCredentials.secretText).Password -$jsonresp = - @{clientId=$sp.appId - clientSecret=$clientsec - subscriptionId=$SubscriptionId - tenantId=$TenantId - activeDirectoryEndpointUrl='https://login.microsoftonline.com' - resourceManagerEndpointUrl='https://management.azure.com/' - activeDirectoryGraphResourceId='https://graph.windows.net/' - sqlManagementEndpointUrl='https://management.core.windows.net:8443/' - galleryEndpointUrl='https://gallery.azure.com/' - managementEndpointUrl='https://management.core.windows.net/' - } -$jsonresp | ConvertTo-Json - diff --git a/.github/scripts/iac/New-SelfSignedCert.ps1 b/.github/scripts/iac/New-SelfSignedCert.ps1 deleted file mode 100644 index 9eed904..0000000 --- a/.github/scripts/iac/New-SelfSignedCert.ps1 +++ /dev/null @@ -1,88 +0,0 @@ -#################################################################################### -# To execute -# 1. Run Powershell as ADMINistrator -# 2. In powershell, set security polilcy for this script: -# Set-ExecutionPolicy Unrestricted -Scope Process -Force -# 3. Change directory to the script folder: -# CD C:\Scripts (wherever your script is) -# 4. In powershell, run script: -# .\New-SelfSignedCert.ps1 -Path Certs\ -File b2c-dev-SAML.pfx -Domain b2c-dev-Saml.MyCo.onmicrosoft.com -#################################################################################### - -param ( - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$Path = $(throw '-Path is a required parameter.'), #.\Certs - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$File = $(throw '-File is a required parameter.'), #MyCert.pfx - [Parameter(Mandatory=$true,ValueFromPipelineByPropertyName=$true)] - [string]$Domain = $(throw '-Domain is a required parameter.'), #dev.mydomain.com, yourappname.yourtenant.onmicrosoft.com - [string]$CertStoreLocation = 'Cert:\LocalMachine\My', - [int]$ExpirationMonths = 24, - [int]$KeyLength = 2048, - [SecureString]$Password = (ConvertTo-SecureString -String (New-Guid).ToString() -Force -AsPlainText) -) - -#################################################################################### -Set-ExecutionPolicy Unrestricted -Scope Process -Force -$VerbosePreference = 'SilentlyContinue' # 'SilentlyContinue' # 'Continue' -if ($MyInvocation.MyCommand.Path) { - [String]$ThisScript = $MyInvocation.MyCommand.Path -} else { - [String]$ThisScript = (Get-Location).Path -} -[String]$ThisDir = Split-Path $ThisScript -Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths - -Write-Host "*****************************" -Write-Host "*** Starting: $ThisScript On: $(Get-Date)" -Write-Host "*****************************" -#################################################################################### -# Imports -Import-Module "../../System.psm1" -$crlf = "`r`n" - -$Path = Remove-Suffix -String $Path -Remove "\" -$File = Remove-Prefix -String $File -Remove "." -$File = Remove-Prefix -String $File -Remove "." -$File = Remove-Prefix -String $File -Remove "\" -[String] $FilePath = "$Path\$File" - -New-Path $Path - -# Remove existing cert -$foundCert = Get-ChildItem -Path $CertStoreLocation | Where-Object Subject -eq "CN=$Domain" | Select-Object * -if($foundCert.Thumbprint.Length -gt 0) -{ - ## Found, remove it now - $thumbprint=$foundCert.Thumbprint - Write-Verbose "$crlf[Verbose] Found thumbprint $thumbprint $crlf" - Get-ChildItem "Cert:\LocalMachine\My\$thumbprint" | Remove-Item - Write-Verbose "$crlf[Verbose] Removed thumbprint $thumbprint $crlf" - $thumbprint = "" -} - -$EKU = @("1.3.6.1.5.5.7.3.1") # Server Authentication -# $EKU = @("1.3.6.1.5.5.7.3.2") # Client Authentication instead of Server Authentication -# $EKU = @("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2") # Both Server and Client Authentication - -$Certificate = New-SelfSignedCertificate ` - -KeyExportPolicy Exportable ` - -Subject "CN=$Domain" ` - -KeyAlgorithm RSA ` - -KeyLength $KeyLength ` - -KeyUsage DigitalSignature ` - -NotAfter (Get-Date).AddMonths($ExpirationMonths) ` - -CertStoreLocation $CertStoreLocation ` - -TextExtension @("2.5.29.37={text}$($EKU -join ',')") -$thumbprint = $Certificate.Thumbprint -Write-Host "$crlf[Info] Created $CertStoreLocation with thumbprint $thumbprint $crlf" - -# Export Pfx - -# Export Pfx using the provided SecureString password -Export-PfxCertificate -Cert "$CertStoreLocation\$thumbprint" -FilePath $FilePath -Password $Password -Write-Host "$crlf[Info] Created $FilePath with provided password $crlf" - -# Export Cer -Export-Certificate -Cert "$CertStoreLocation\$thumbprint" -Filepath "$FilePath.cer" #-Type CERT -NoClobber -Write-Host "$crlf[Info] Created $FilePath.cer $crlf" diff --git a/.github/scripts/iac/Remove-LandingZone.ps1 b/.github/scripts/iac/Remove-LandingZone.ps1 deleted file mode 100644 index 21a2675..0000000 --- a/.github/scripts/iac/Remove-LandingZone.ps1 +++ /dev/null @@ -1,48 +0,0 @@ -#----------------------------------------------------------------------- -# Remove-LandingZone [-TenantId []] [-SubscriptionId []] -# -# Example: .\Remove-LandingZone -TenantId -SubscriptionId -ResourceGroup -KeyVault -StorageAccount -Workspace -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -param -( - [string] $TenantId=$(throw '-TenantId is a required parameter. (00000000-0000-0000-0000-000000000000)'), - [string] $SubscriptionId=$(throw '-TenantId is a required parameter. (00000000-0000-0000-0000-000000000000)'), - [string] $ResourceGroup=$(throw '-ResourceGroup is a required parameter. (rg-PRODUCT-ENVIRONMENT-001)'), - [string] $KeyVault=$(throw '-KeyVault is a required parameter. (kv-PRODUCT-ENVIRONMENT-001)'), - [string] $StorageAccount=$(throw '-StorageAccount is a required parameter. (stPRODUCTENVIRONMENT001)'), - [string] $Workspace=$(throw '-Workspace is a required parameter. (work-PRODUCT-ENVIRONMENT-001)') -) - -# *** -# *** Initialize -# *** -if ($IsWindows) { Set-ExecutionPolicy Unrestricted -Scope Process -Force } -$VerbosePreference = 'SilentlyContinue' #'Continue' -[String]$ThisScript = $MyInvocation.MyCommand.Path -[String]$ThisDir = Split-Path $ThisScript -[DateTime]$Now = Get-Date -Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths -Write-Host "*****************************" -Write-Host "*** Starting: $ThisScript on $Now" -Write-Host "*****************************" -# Imports -Import-Module "../System.psm1" -Install-Module -Name Az.Accounts -AllowClobber -Scope CurrentUser -Install-Module -Name Az.Resources -AllowClobber -Scope CurrentUser - -# *** -# *** Auth -# *** -Write-Host "*** Auth ***" -Connect-AzAccount -Tenant $TenantId -Subscription $SubscriptionId - -# *** -# *** Execute -# *** -Remove-AzStorageAccount -ResourceGroupName $ResourceGroup -AccountName $StorageAccount -Remove-AzKeyVault -VaultName $KeyVault -PassThru -Remove-AzOperationalInsightsWorkspace -ResourceGroupName $ResourceGroup -Name $Workspace -ForceDelete \ No newline at end of file diff --git a/.github/scripts/iac/Set-AzKeyVaultPolicy.ps1 b/.github/scripts/iac/Set-AzKeyVaultPolicy.ps1 deleted file mode 100644 index 8a9fac3..0000000 --- a/.github/scripts/iac/Set-AzKeyVaultPolicy.ps1 +++ /dev/null @@ -1,43 +0,0 @@ -#----------------------------------------------------------------------- -# Set-AzKeyVaultPolicy [-Name []] [-ObjectId []] [-SecretPermissions []] -# -# Example: .\Set-AzKeyVaultPolicy -Name kv-PRODUCT-ENVIRONMENT-001 -ObjectId 00000000-0000-0000-0000-000000000000 -SecretPermissions list get -# CLI: -# az keyvault set-policy --name "" --spn --secret-permissions list get -# az keyvault update --name "" --resource-group "" --enabled-for-deployment "true" -#----------------------------------------------------------------------- - -# *** -# *** Parameters -# *** -param -( - [string] $Name=$(throw '-Name is a required parameter. (kv-PRODUCT-ENVIRONMENT-001)'), - [string] $ObjectId=$(throw '-ObjectId is a required parameter. (00000000-0000-0000-0000-000000000000)'), - [string] $SecretPermissions='list get' -) - -# *** -# *** Initialize -# *** -if ($IsWindows) { Set-ExecutionPolicy Unrestricted -Scope Process -Force } -$VerbosePreference = 'SilentlyContinue' #'Continue' -[String]$ThisScript = $MyInvocation.MyCommand.Path -[String]$ThisDir = Split-Path $ThisScript -[DateTime]$Now = Get-Date -Set-Location $ThisDir # Ensure our location is correct, so we can use relative paths -Write-Host "*****************************" -Write-Host "*** Starting: $ThisScript on $Now" -Write-Host "*****************************" -# Imports -Import-Module "../System.psm1" -Install-Module -Name Az.Accounts -AllowClobber -Scope CurrentUser -Install-Module -Name Az.Resources -AllowClobber -Scope CurrentUser - -# *** -# *** Auth -# *** -Write-Host "*** Auth ***" -Connect-AzAccount -Tenant $TenantId -Subscription $SubscriptionId - -Set-AzKeyVaultAccessPolicy -VaultName $Name -ObjectId $ObjectId -PermissionsToSecrets $SecretPermissions -EnabledForDeployment \ No newline at end of file diff --git a/.github/scripts/repo/az-create-for-rbac.cmd b/.github/scripts/repo/az-create-for-rbac.cmd deleted file mode 100644 index e3ed6c0..0000000 --- a/.github/scripts/repo/az-create-for-rbac.cmd +++ /dev/null @@ -1,19 +0,0 @@ -REM -ECHO *** az-create-for-rbac.cmd *** -REM *** Usage: az-create-for-rbac.cmd - -REM *** Locals -SET FullPath=%1 -SET FullPath=%FullPath:"=% - - - -exit 0 - - - -az login - -az ad sp create-for-rbac --name "myApp" --role contributor \ - --scopes /subscriptions/{subscription-id}/resourceGroups/{resource-group} \ - --sdk-auth \ No newline at end of file diff --git a/README.md b/README.md index 596e378..9414938 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,20 @@ To get started, follow the steps below: ``` dotnet tool install --global dotnet-ef ``` +3. **Configure Entra External ID (EEID) Authentication** + + This project now uses Entra External ID (EEID) authentication. You must create two Entra app registrations (Web and API) and configure all required EEID settings. Use the provided script to automate this process: + + ```powershell + ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl https://.ciamlogin.com -TenantId -WebAppRegistrationName semker-web-dev-001 -ApiAppRegistrationName semker-api-dev-001 -WebProjectPath "./src/Presentation.Blazor" -ApiProjectPath "./src/Presentation.WebApi" + ``` + + - The script will set all required EEID configuration values via `dotnet user-secrets set` in the respective project folders. + - You will be prompted to open the Azure Portal and grant admin consent for permissions twice (once for the API app and once for the Web app). + - After completion, the script will output a summary table with all relevant IDs (TenantId, Instance, AppIds, ObjectIds, Redirect URIs, etc.) for your reference. + + See the **Authentication** section below for more details. + 3. Add your Open AI or Azure Open AI key to configuration (via *dotnet user-secrets set* command) ``` cd src/Presentation.WebApi @@ -96,6 +110,29 @@ Visual Studio installs SQL Express. If you want full-featured SQL Server, instal [SQL Server Developer Edition or above](https://www.microsoft.com/en-us/sql-server/sql-server-downloads) +# Authentication (Entra External ID) + +This project uses Entra External ID (EEID) for authentication. You must: + +1. Run the provided script to create and configure the required Entra app registrations for both the Web and API projects (see Quick-Start Step 3 above). +2. The script will automatically set all necessary EEID configuration values in each project's user-secrets store. +3. If you need to manually review or update these values, check the user-secrets for each project: + - `src/Presentation.Blazor` (Web) + - `src/Presentation.WebApi` (API) + +**EEID configuration values include:** + - Entra Instance URL + - Tenant ID + - Client IDs for Web and API + - Client secrets (if applicable) + - Redirect URIs + - API scopes + +**Note:** +- You must use the correct Entra instance and tenant for your environment. +- The app registration names and GUIDs in the script are examples—replace them with your own values. +- For more details on Entra External ID, see [Microsoft Entra External ID documentation](https://learn.microsoft.com/en-us/azure/active-directory/external-identities/). + # Configure API Key and Connection String Follow these steps to get your development environment set up: @@ -174,6 +211,7 @@ dotnet user-secrets set "ConnectionStrings:DefaultConnection" "YOUR_SQL_CONNECTI ``` dotnet ef migrations add v1.1.1 --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext ``` + # Running the Application ## Launch the backend Right-click Presentation.WebApi and select Set as Default Project diff --git a/src/Presentation.Blazor/appsettings.Local.json b/src/Presentation.Blazor/appsettings.Local.json index b749e51..2d69954 100644 --- a/src/Presentation.Blazor/appsettings.Local.json +++ b/src/Presentation.Blazor/appsettings.Local.json @@ -6,7 +6,7 @@ } }, "ApplicationInsights": { - "ConnectionString": "" + "ConnectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" }, "Clarity": { "ProjectId": "" diff --git a/src/Presentation.WebApi/appsettings.local.json b/src/Presentation.WebApi/appsettings.local.json index fec1ed8..6a3bf00 100644 --- a/src/Presentation.WebApi/appsettings.local.json +++ b/src/Presentation.WebApi/appsettings.local.json @@ -7,7 +7,7 @@ } }, "ApplicationInsights": { - "ConnectionString": "" + "ConnectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" }, "ConnectionStrings": { "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=DigitalInsights;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" From 2dc564d200f41ba33169653c92fadd805195afc4 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 00:54:15 -0800 Subject: [PATCH 6/9] app registration works --- .azure/scripts/entra/New-EntraAppRegistrations.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/.azure/scripts/entra/New-EntraAppRegistrations.ps1 b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 index 3f02d4a..d166927 100644 --- a/.azure/scripts/entra/New-EntraAppRegistrations.ps1 +++ b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 @@ -433,6 +433,7 @@ $webApp = $webReg # Step 6: Write Web EEID values to $WebProjectPath via dotnet user-secrets $webSecrets = @{ + "BackEndApi:ClientId" = $apiApp.AppId "EntraExternalId:Instance" = $EntraInstanceUrl "EntraExternalId:TenantId" = $TenantId "EntraExternalId:ClientId" = $webApp.AppId From 7e871852e3fc51b7ca3450aa7035c995479ffa1f Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 08:30:02 -0800 Subject: [PATCH 7/9] wrong connection string --- src/Presentation.WebApi/appsettings.local.json | 2 +- src/Tests.Specs.Integration/TestBase.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Presentation.WebApi/appsettings.local.json b/src/Presentation.WebApi/appsettings.local.json index 6a3bf00..697f99a 100644 --- a/src/Presentation.WebApi/appsettings.local.json +++ b/src/Presentation.WebApi/appsettings.local.json @@ -10,7 +10,7 @@ "ConnectionString": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" }, "ConnectionStrings": { - "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=DigitalInsights;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + "DefaultConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" }, "AllowedHosts": "*", "EntraExternalId": { diff --git a/src/Tests.Specs.Integration/TestBase.cs b/src/Tests.Specs.Integration/TestBase.cs index 23a19a5..9e636d2 100644 --- a/src/Tests.Specs.Integration/TestBase.cs +++ b/src/Tests.Specs.Integration/TestBase.cs @@ -49,7 +49,7 @@ public TestBase() .AddEnvironmentVariables() .Build(); - // The SK Plugins currently rely on GetRequiredService(), so we need to register it as a scoped service. + // The SK Plugins currently rely on GetRequiredService(), so we need to register it as a scoped service. // This is a workaround to allow the plugins to be registered in the DI container as Singleton which SK memory wants, despite an EF dependency which wants Scoped. var services = new ServiceCollection(); services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); From ff92fa90a682a5c278c9447eba08ead95c14bf59 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 08:50:15 -0800 Subject: [PATCH 8/9] version bump --- .github/workflows/gtc-semker-standalone-web-api-sql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gtc-semker-standalone-web-api-sql.yml b/.github/workflows/gtc-semker-standalone-web-api-sql.yml index 019adbd..4adf1ac 100644 --- a/.github/workflows/gtc-semker-standalone-web-api-sql.yml +++ b/.github/workflows/gtc-semker-standalone-web-api-sql.yml @@ -61,8 +61,8 @@ jobs: INFRA_DBCONTEXT: 'SemanticKernelContext' RUNTIME_ENV: "Development" CONFIGURATION: "Release" - VERSION_MAJOR: '1' - VERSION_MINOR: '1' + VERSION_MAJOR: '2' + VERSION_MINOR: '0' steps: - name: checkout From 64544c2c27a0aa80f0a3cbf6063360b8824cd7f1 Mon Sep 17 00:00:00 2001 From: Robert Good Date: Sat, 31 Jan 2026 09:12:49 -0800 Subject: [PATCH 9/9] docs updated --- .../scripts/entra/Set-ApiAppUserSecrets.ps1 | 16 +++ .../scripts/entra/Set-WebAppUserSecrets.ps1 | 16 +++ README.md | 132 ++++++++++++++---- 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 index 1c8f446..05374d2 100644 --- a/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 +++ b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 @@ -1,3 +1,19 @@ +# ============================================================================ +# Script Name: Set-ApiAppUserSecrets.ps1 +# Description: Reads an existing API App Registration and sets .NET user-secrets. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./Set-ApiAppUserSecrets.ps1 ` +# -TenantId "" ` +# -ApiAppRegistrationName "myproduct-api-dev-001" ` +# -ApiProjectPath "../../src/Presentation.WebApi" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Microsoft.Graph.Applications) +# - Ensure you are authenticated: Connect-AzAccount +# - This script does NOT create or modify app registrations. +# ============================================================================ + param( [string]$TenantId, [string]$ApiAppRegistrationName, diff --git a/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 index 41014e0..48475af 100644 --- a/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 +++ b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 @@ -1,3 +1,19 @@ +# ============================================================================ +# Script Name: Set-WebAppUserSecrets.ps1 +# Description: Reads an existing Web App Registration and sets .NET user-secrets. +# ----------------------------------------------------------------------------- +# Example CLI Usage: +# pwsh -File ./Set-WebAppUserSecrets.ps1 ` +# -TenantId "" ` +# -WebAppRegistrationName "myproduct-web-dev-001" ` +# -WebProjectPath "../../src/Presentation.Blazor" +# ----------------------------------------------------------------------------- +# Notes: +# - Requires Azure PowerShell modules (Az.Accounts, Microsoft.Graph.Applications) +# - Ensure you are authenticated: Connect-AzAccount +# - This script does NOT create or modify app registrations. +# ============================================================================ + param( [string]$TenantId, [string]$WebAppRegistrationName, diff --git a/README.md b/README.md index 9414938..f12fc70 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Semantic Kernel C# Microservice Quick-Start -**Azure Bicep Infrastucture** +# Semantic Kernel C# Quick-Start +**Azure Bicep Infrastucture as Standalone Landing Zone** -[![.github/workflows/gtc-rg-semkernel-iac.yml](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-rg-semkernel-iac.yml/badge.svg)](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-rg-semkernel-iac.yml) +[![Landing Zone IaC](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-semker-standalone-iac.yml/badge.svg)](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-semker-standalone-iac.yml) -**Clean Architecture C# Microservice** +**Clean Architecture C# Blazor Microapp and Web API Microservice** -[![.github/workflows/gtc-rg-semkernel-api-ci-cd.yml](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-rg-semkernel-api-ci-cd.yml/badge.svg)](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-rg-semkernel-api-ci-cd.yml) +[![Web & API & SQL CI/CD](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-semker-standalone-web-api-sql.yml/badge.svg)](https://github.com/goodtocode/semantickernel-microservice/actions/workflows/gtc-semker-standalone-web-api-sql.yml) Semantic Kernel Quick-start is a .NET Web API CRUD Microservice solution with Blazor Copilot-ish Chat client that demonstrates the most basic use cases of the Microsoft Semantic Kernel in a Clean Architecture C# Microservice. This microservice allows you to persist the following Azure Open AI services to SQL Server, so you can replay messages and maintain history of your interaction with AI. @@ -40,26 +40,55 @@ To get started, follow the steps below: ``` 2. Install Prerequisites ``` - winget install Microsoft.DotNet.SDK.9 --silent + winget install Microsoft.DotNet.SDK.10 --silent ``` ``` dotnet tool install --global dotnet-ef ``` 3. **Configure Entra External ID (EEID) Authentication** - This project now uses Entra External ID (EEID) authentication. You must create two Entra app registrations (Web and API) and configure all required EEID settings. Use the provided script to automate this process: + **IMPORTANT:** This product depends on Entra External ID. This means you must have an EEID tenant or create one at https://portal.azure.com. An empty tenant will work. Please remember to create a break-glass user as a backup in cse you get locked out. + + Configure EEID authentication using one of the following methods: + 1. If you do not have app registrations, run `New-EntraAppRegistrations.ps1` to create both Web and API app registrations and set all user-secrets (admin consent required). + ``` + pwsh -File ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -ApiAppRegistrationName "myproduct-api-dev-001" -WebProjectPath "../../src/Presentation.Blazor" -ApiProjectPath "../../src/Presentation.WebApi" + ``` - ```powershell - ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl https://.ciamlogin.com -TenantId -WebAppRegistrationName semker-web-dev-001 -ApiAppRegistrationName semker-api-dev-001 -WebProjectPath "./src/Presentation.Blazor" -ApiProjectPath "./src/Presentation.WebApi" - ``` + 2. **OR** Run `Set-WebAppUserSecrets.ps1` and `Set-ApiAppUserSecrets.ps1` to set user-secrets from your account. + ``` + pwsh -File ./.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 -TenantId "" -ApiAppRegistrationName "myproduct-api-dev-001" -ApiProjectPath "../../src/Presentation.WebApi" + ``` + ``` + pwsh -File ./.azure/scripts/entra/Set-WebAppUserSecrets.ps1 -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -WebProjectPath "../../src/Presentation.Blazor" + ``` - - The script will set all required EEID configuration values via `dotnet user-secrets set` in the respective project folders. - - You will be prompted to open the Azure Portal and grant admin consent for permissions twice (once for the API app and once for the Web app). - - After completion, the script will output a summary table with all relevant IDs (TenantId, Instance, AppIds, ObjectIds, Redirect URIs, etc.) for your reference. + 3. **OR** Manually set the required values using `dotnet user-secrets set` (or appsettings.local.json, not recommended for secrets). + ``` + cd src/Presentation.WebApi + dotnet user-secrets init + dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" + dotnet user-secrets set "EntraExternalId:TenantId" "" + dotnet user-secrets set "EntraExternalId:ClientId" "" + dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" + ``` - See the **Authentication** section below for more details. + ``` + cd src/Presentation.WebApi + dotnet user-secrets init + dotnet user-secrets set "BackEndApi:ClientId" "" + dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" + dotnet user-secrets set "EntraExternalId:TenantId" "" + dotnet user-secrets set "EntraExternalId:ClientId" "" + dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" + dotnet user-secrets set "EntraExternalId:ClientSecret" "" -3. Add your Open AI or Azure Open AI key to configuration (via *dotnet user-secrets set* command) + cd ../../ + ``` + + See the **Authentication** section below for details and examples. + +4. Add your Open AI or Azure Open AI key to configuration (via *dotnet user-secrets set* command) ``` cd src/Presentation.WebApi dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" @@ -68,23 +97,23 @@ To get started, follow the steps below: cd ../Tests.Specs.Integration dotnet user-secrets set "OpenAI:ApiKey" "YOUR_API_KEY" ``` -4. Create your SQL Server database & schema (via *dotnet ef* command) +5. Create your SQL Server database & schema (via *dotnet ef* command) ``` cd ../../ dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context SemanticKernelContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=SemanticKernel;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30" ``` -5. Run Tests (Tests.Specs.Integration) +6. Run Tests (Tests.Specs.Integration) ``` cd src/Tests.Specs.Integration dotnet test ``` -6. Run Blazor Web Chat Client (Presentation.Blazor) and Web API (Presentation.WebApi) +7. Run Blazor Web Chat Client (Presentation.Blazor) and Web API (Presentation.WebApi) ``` cd ../ dotnet run --project Presentation.WebApi/Presentation.WebApi.csproj dotnet run --project Presentation.Blazor/Presentation.Blazor.csproj ``` - # Note: By default, Presentation.WebApi runs on http://localhost:7777 and Presentation.Blazor runs on http://localhost:5000 unless configured otherwise. + **Note:** By default, Presentation.WebApi runs on https://localhost:6075 and Presentation.Blazor runs on https://localhost:7175 unless configured otherwise. # Install Prerequisites You will need the following tools: @@ -96,7 +125,7 @@ winget install --id Microsoft.VisualStudio.2022.Community --override "--quiet -- ## .NET SDK ``` -winget install Microsoft.DotNet.SDK.9 --silent +winget install Microsoft.DotNet.SDK.10 --silent ``` ## dotnet ef cli @@ -110,15 +139,66 @@ Visual Studio installs SQL Express. If you want full-featured SQL Server, instal [SQL Server Developer Edition or above](https://www.microsoft.com/en-us/sql-server/sql-server-downloads) + + # Authentication (Entra External ID) -This project uses Entra External ID (EEID) for authentication. You must: +This project uses Entra External ID (EEID) for authentication. You only need to complete ONE of the following methods (they are alternatives, not cumulative). **The preferred approach is to use the script to create both app registrations, as this will configure all required claims, roles, and scopes custom to this quick-start.** + +**1. Create both app registrations and configure automatically (recommended for most users):** + +If you do not have app registrations, run the script below to create both Web and API app registrations and set all user-secrets (admin consent required): + +``` +pwsh -File ./.azure/scripts/entra/New-EntraAppRegistrations.ps1 -EntraInstanceUrl "https://your-tenant-name.ciamlogin.com" -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -ApiAppRegistrationName "myproduct-api-dev-001" -WebProjectPath "../../src/Presentation.Blazor" -ApiProjectPath "../../src/Presentation.WebApi" +``` -1. Run the provided script to create and configure the required Entra app registrations for both the Web and API projects (see Quick-Start Step 3 above). -2. The script will automatically set all necessary EEID configuration values in each project's user-secrets store. -3. If you need to manually review or update these values, check the user-secrets for each project: - - `src/Presentation.Blazor` (Web) - - `src/Presentation.WebApi` (API) +You will be prompted to grant admin consent in the Azure Portal twice (once for each app registration: Web and API). Look for a console message like this for each app: + +``` +ACTION REQUIRED: Grant admin consent for Web app permissions in the Azure Portal: +Open the following URL in your browser: +https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Permissions/appId//isMSAApp~/false +Then click 'Grant admin consent for ...' in the API permissions blade. +``` +The script will output a summary table with all relevant IDs (TenantId, Instance, AppIds, ObjectIds, Redirect URIs, etc.) for your reference. + +**2. OR: Use existing app registrations and configure .NET secrets:** + +If you already have app registrations, run the following scripts to set user-secrets from your account: + +``` +pwsh -File ./.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 -TenantId "" -ApiAppRegistrationName "myproduct-api-dev-001" -ApiProjectPath "../../src/Presentation.WebApi" +``` +``` +pwsh -File ./.azure/scripts/entra/Set-WebAppUserSecrets.ps1 -TenantId "" -WebAppRegistrationName "myproduct-web-dev-001" -WebProjectPath "../../src/Presentation.Blazor" +``` + +**3. OR: Configure everything manually:** + +Set the required values using `dotnet user-secrets set` (or appsettings.local.json, not recommended for secrets): + +``` +cd src/Presentation.WebApi +dotnet user-secrets init +dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" +dotnet user-secrets set "EntraExternalId:TenantId" "" +dotnet user-secrets set "EntraExternalId:ClientId" "" +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +``` + +``` +cd src/Presentation.WebApi +dotnet user-secrets init +dotnet user-secrets set "BackEndApi:ClientId" "" +dotnet user-secrets set "EntraExternalId:Instance" "https://your-tenant-name.ciamlogin.com" +dotnet user-secrets set "EntraExternalId:TenantId" "" +dotnet user-secrets set "EntraExternalId:ClientId" "" +dotnet user-secrets set "EntraExternalId:ValidateAuthority" "true" +dotnet user-secrets set "EntraExternalId:ClientSecret" "" + +cd ../../ +``` **EEID configuration values include:** - Entra Instance URL