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/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..d166927 --- /dev/null +++ b/.azure/scripts/entra/New-EntraAppRegistrations.ps1 @@ -0,0 +1,446 @@ +# ============================================================================ +# 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 = @{ + "BackEndApi:ClientId" = $apiApp.AppId + "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..05374d2 --- /dev/null +++ b/.azure/scripts/entra/Set-ApiAppUserSecrets.ps1 @@ -0,0 +1,78 @@ +# ============================================================================ +# 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, + [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..48475af --- /dev/null +++ b/.azure/scripts/entra/Set-WebAppUserSecrets.ps1 @@ -0,0 +1,84 @@ +# ============================================================================ +# 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, + [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/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 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/.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 diff --git a/README.md b/README.md index 596e378..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,12 +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. Add your Open AI or Azure Open AI key to configuration (via *dotnet user-secrets set* command) +3. **Configure Entra External ID (EEID) Authentication** + + **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" + ``` + + 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" + ``` + + 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" + ``` + + ``` + 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 ../../ + ``` + + 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" @@ -54,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: @@ -82,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 @@ -96,6 +139,80 @@ 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 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" +``` + +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 + - 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 +291,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.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..2d69954 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": "InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=applicationinsights.azure.com" }, "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..697f99a 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": "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": { - "Instance": "https://goodtocodesecuredev.ciamlogin.com/", - "TenantId": "19800ad2-82e2-4946-abd5-c87ba78a7224", - "ClientId": "c0150160-f59e-4798-933a-a06fcf5193d6", + "Instance": "", + "TenantId": "", + "ClientId": "", "ValidateAuthority": true }, "OpenAI": { 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()));