GraphAppToolkit.psm1
#Region '.\Classes\TkEmailAppParams.ps1' -1 class TkEmailAppParams { [string]$AppId [string]$Id [string]$AppName [string]$CertificateSubject [string]$AppRestrictedSendGroup [string]$CertExpires [string]$CertThumbprint [string]$ConsentUrl [string]$DefaultDomain [string]$SendAsUser [string]$SendAsUserEmail [string]$TenantID # Constructor TkEmailAppParams( [string]$AppId, [string]$Id, [string]$AppName, [string]$CertificateSubject, [string]$AppRestrictedSendGroup, [string]$CertExpires, [string]$CertThumbprint, [string]$ConsentUrl, [string]$DefaultDomain, [string]$SendAsUser, [string]$SendAsUserEmail, [string]$TenantID ) { $this.AppId = $AppId $this.Id = $Id $this.AppName = $AppName $this.CertificateSubject = $CertificateSubject $this.AppRestrictedSendGroup = $AppRestrictedSendGroup $this.CertExpires = $CertExpires $this.CertThumbprint = $CertThumbprint $this.ConsentUrl = $ConsentUrl $this.DefaultDomain = $DefaultDomain $this.SendAsUser = $SendAsUser $this.SendAsUserEmail = $SendAsUserEmail $this.TenantID = $TenantID } # (Optional) A helper method that converts CertExpires to a DateTime object [DateTime] GetCertExpiresAsDateTime() { return [DateTime]::Parse($this.CertExpires) } } #EndRegion '.\Classes\TkEmailAppParams.ps1' 47 #Region '.\Classes\TkM365AuditAppParams.ps1' -1 class TkM365AuditAppParams { [string]$AppName [string]$AppId [string]$ObjectId [string]$TenantId [string]$CertThumbprint [string]$CertExpires [string]$ConsentUrl [string]$MgGraphPermissions [string]$SharePointPermissions [string]$ExchangePermissions # Constructor TkM365AuditAppParams( [string]$AppName, [string]$AppId, [string]$ObjectId, [string]$TenantId, [string]$CertThumbprint, [string]$CertExpires, [string]$ConsentUrl, [string]$MgGraphPermissions, [string]$SharePointPermissions, [string]$ExchangePermissions ) { $this.AppName = $AppName $this.AppId = $AppId $this.ObjectId = $ObjectId $this.TenantId = $TenantId $this.CertThumbprint = $CertThumbprint $this.CertExpires = $CertExpires $this.ConsentUrl = $ConsentUrl $this.MgGraphPermissions = $MgGraphPermissions $this.SharePointPermissions = $SharePointPermissions $this.ExchangePermissions = $ExchangePermissions } [DateTime] GetCertExpiresAsDateTime() { return [DateTime]::Parse($this.CertExpires) } # (Optional) Helper methods to split space-delimited permissions into arrays: [string[]]GetMgGraphPermissionsArray() { return $this.MgGraphPermissions -split '\s+' } [string[]]GetSharePointPermissionsArray() { return $this.SharePointPermissions -split '\s+' } [string[]]GetExchangePermissionsArray() { return $this.ExchangePermissions -split '\s+' } } #EndRegion '.\Classes\TkM365AuditAppParams.ps1' 50 #Region '.\Classes\TkMemPolicyManagerAppParams .ps1' -1 class TkMemPolicyManagerAppParams { [string]$AppId [string]$AppName [string]$CertThumbprint [string]$ObjectId [string]$ConsentUrl [string]$PermissionSet [string]$Permissions [string]$TenantId # Constructor TkMemPolicyManagerAppParams ( [string]$AppId, [string]$AppName, [string]$CertThumbprint, [string]$ObjectId, [string]$ConsentUrl, [string]$PermissionSet, [string]$Permissions, [string]$TenantId ) { $this.AppId = $AppId $this.AppName = $AppName $this.CertThumbprint = $CertThumbprint $this.ObjectId = $ObjectId $this.ConsentUrl = $ConsentUrl $this.PermissionSet = $PermissionSet $this.Permissions = $Permissions $this.TenantId = $TenantId } # (Optional) Helper method to split the Permissions string into an array: [string[]] GetPermissionsArray() { return $this.Permissions -split '\s+' } } #EndRegion '.\Classes\TkMemPolicyManagerAppParams .ps1' 36 #Region '.\Private\Connect-TkMsService.ps1' -1 <# .SYNOPSIS Connects to Microsoft Graph and/or Exchange Online services. .DESCRIPTION The Connect-TkMsService function establishes a connection to Microsoft Graph and/or Exchange Online services. It checks for existing sessions and reuses them if valid, otherwise, it creates new sessions. The function supports logging and provides detailed information about the connection process. .PARAMETER MgGraph Switch parameter to indicate if a connection to Microsoft Graph should be established. .PARAMETER GraphAuthScopes Array of strings specifying the scopes required for Microsoft Graph authentication. .PARAMETER ExchangeOnline Switch parameter to indicate if a connection to Exchange Online should be established. .EXAMPLE Connect-TkMsService -MgGraph -GraphAuthScopes @('User.Read', 'Mail.Read') This example connects to Microsoft Graph with the specified scopes. .EXAMPLE Connect-TkMsService -ExchangeOnline This example connects to Exchange Online. .EXAMPLE Connect-TkMsService -MgGraph -GraphAuthScopes @('User.Read', 'Mail.Read') -ExchangeOnline This example connects to both Microsoft Graph and Exchange Online. .NOTES This function requires the Microsoft.Graph and ExchangeOnlineManagement modules to be installed and imported. #> function Connect-TkMsService { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param ( [Parameter( HelpMessage = 'Switch to connect to Microsoft Graph.' )] [Switch] $MgGraph, [Parameter( HelpMessage = 'Array of scopes required for Microsoft Graph authentication.' )] [String[]] $GraphAuthScopes, [Parameter( HelpMessage = 'Switch to connect to Exchange Online.' )] [Switch] $ExchangeOnline ) # Begin Logging if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' #---------------------------------------------- # Section 1: Microsoft Graph #---------------------------------------------- if ($MgGraph) { $shouldProcessTarget = $GraphAuthScopes -join ', ' $shouldProcessOperation = 'Connect-MgGraph' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { try { # 1) Attempt to see if we have a valid Graph session $graphIsValid = $false try { # If this succeeds, presumably we have a valid token/context Get-MgUser -Top 1 -ErrorAction Stop | Out-Null $ContextMg = Get-MgContext -ErrorAction Stop # Check required scopes $scopesNeeded = $GraphAuthScopes $missing = $scopesNeeded | Where-Object { $ContextMg.Scopes -notcontains $_ } if ($missing) { Write-AuditLog "The following needed scopes are missing: $($missing -join ', ')" } else { Write-AuditLog 'An active Microsoft Graph session is detected and all required scopes are present.' $graphIsValid = $true } } catch { # Either no session or it's invalid/expired $graphIsValid = $false } # 2) If valid session, ask user if they want to reuse it if ($graphIsValid) { $org = Get-MgOrganization -ErrorAction Stop $shouldProcessTarget = 'Microsoft Graph' $shouldProcessOperation = "Use existing session for Account: $($ContextMg.Account) Tenant: $($org.DisplayName) AuthType: $($ContextMg.AuthType)" if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { Write-AuditLog 'Using existing Microsoft Graph session.' } else { # Remove the old context so we can connect fresh Remove-MgContext -ErrorAction SilentlyContinue Write-AuditLog 'Creating a new Microsoft Graph session.' Connect-MgGraph -ContextScope Process -Scopes $scopesNeeded ` -ErrorAction Stop | Out-Null Write-AuditLog 'Connected to Microsoft Graph.' } } else { # No valid session, so just connect Write-AuditLog 'No valid Microsoft Graph session found. Connecting...' Connect-MgGraph -ContextScope Process -Scopes $scopesNeeded ` -ErrorAction Stop | Out-Null Write-AuditLog 'Connected to Microsoft Graph.' } } catch { Write-AuditLog -Severity Error -Message "Error connecting to Microsoft Graph. Error: $($_.Exception.Message)" throw } } } #---------------------------------------------- # Section 2: Exchange Online #---------------------------------------------- if ($ExchangeOnline) { $shouldProcessTarget = 'Connecting to Exchange Online using modern authentication pop-up.' $shouldProcessOperation = 'Connect-ExchangeOnline' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { try { # 1) Attempt to see if we have a valid Exchange session $exoIsValid = $false try { $ExoOrg = Get-OrganizationConfig -ErrorAction Stop $exoIsValid = $true } catch { # Either no session or it's invalid/expired $exoIsValid = $false } # 2) If valid session, ask user if they want to reuse it if ($exoIsValid) { Write-AuditLog 'An active Exchange Online session is detected.' Write-AuditLog "Tenant: `n$($ExoOrg.DisplayName)`n" $shouldProcessTarget = 'ExchangeOnline' $shouldProcessOperation = "Use existing session for Org: $($ExoOrg.DisplayName) OnMicrosoftId: $($ExoOrg.Name )" if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { Write-AuditLog 'Using existing Exchange Online session.' } else { # Disconnect old session Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue Write-AuditLog 'Creating new Exchange Online session.' Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop Write-AuditLog 'Connected to Exchange Online.' } } else { Write-AuditLog 'No valid Exchange Online session found. Connecting...' Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop Write-AuditLog 'Connected to Exchange Online.' } } catch { Write-AuditLog -Severity Error -Message "Error connecting to Exchange Online. Error: $($_.Exception.Message)" throw } } } Write-AuditLog -EndFunction } #EndRegion '.\Private\Connect-TkMsService.ps1' 165 #Region '.\Private\ConvertTo-ParameterSplat.ps1' -1 <# .SYNOPSIS Converts an object's properties to a parameter splatting hashtable script. .DESCRIPTION The ConvertTo-ParameterSplat function takes an input object and converts its properties into a PowerShell hashtable script that can be used for parameter splatting. This is useful for dynamically constructing parameter sets for cmdlets. .PARAMETER InputObject The object whose properties will be converted into a parameter splatting hashtable script. This parameter is mandatory and accepts input from the pipeline. .OUTPUTS System.String The function outputs a string that represents the hashtable script for parameter splatting. .EXAMPLE PS C:\> $obj = [PSCustomObject]@{ Name = "John"; Age = 30 } PS C:\> $obj | ConvertTo-ParameterSplat `$params = @{ Name = "John" Age = 30 } .NOTES Author: DrIOSx Last Updated: 2025-03-16 #> function ConvertTo-ParameterSplat { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, HelpMessage = 'The object whose properties will be converted into a parameter splatting hashtable script.')] [ValidateNotNullOrEmpty()] [PSObject]$InputObject ) process { Write-AuditLog -Message "Starting ConvertTo-ParameterSplat function." -Severity "Information" $splatScript = "`$params = @{`n" $InputObject.psobject.Properties | ForEach-Object { $value = $_.Value if ($value -is [string]) { $value = "`"$value`"" } $splatScript += " $($_.Name) = $value`n" } $splatScript += "}" Write-AuditLog -Message "Completed ConvertTo-ParameterSplat function." -Severity "Information" Write-Output $splatScript } } #EndRegion '.\Private\ConvertTo-ParameterSplat.ps1' 47 #Region '.\Private\Get-TkExistingCert.ps1' -1 <# .SYNOPSIS Retrieves an existing certificate from the current user's certificate store based on the provided certificate name. .DESCRIPTION The Get-TkExistingCert function searches for a certificate in the current user's "My" certificate store with a subject that matches the provided certificate name. If the certificate is found, it logs audit messages and provides instructions for removing the certificate if needed. If the certificate is not found, it logs an audit message indicating that the certificate does not exist. .PARAMETER CertName The subject name of the certificate to search for in the current user's certificate store. .EXAMPLE PS C:\> Get-TkExistingCert -CertName "CN=example.com" This command searches for a certificate with the subject "CN=example.com" in the current user's certificate store. .NOTES Author: DrIOSx Date: 2025-03-12 Version: 1.0 #> function Get-TkExistingCert { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param ( [Parameter(Mandatory = $true, HelpMessage = 'The subject name of the certificate to search for in the current user''s certificate store.')] [string]$CertName ) if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } $ExistingCert = Get-ChildItem -Path Cert:\CurrentUser\My -ErrorAction SilentlyContinue | Where-Object { $_.Subject -eq $CertName } -ErrorAction SilentlyContinue if ( $ExistingCert) { $VerbosePreference = 'Continue' Write-AuditLog "Certificate with subject '$CertName' already exists in the certificate store." Write-AuditLog 'You can remove the old certificate if no longer needed with the following commands:' Write-AuditLog '1. Verify if more than one cert already exists:' Write-AuditLog "Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { `$_.Subject -eq '$CertName' }" Write-AuditLog '2. If you are comfortable removing the old certificate, and any duplicates, run the following command:' Write-AuditLog "Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { `$_.Subject -eq '$CertName' } | Remove-Item" Write-AuditLog 'If you would like to remove the certificate, confirm the operation when prompted.' $shouldProcessOperation = 'Remove-Item' $shouldProcessTarget = "Certificate with subject '$CertName' with thumbprint $($ExistingCert.Thumbprint)" if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Subject -eq $CertName } -ErrorAction Stop | Remove-Item Write-AuditLog "Certificate with subject '$CertName' removed." } else { Write-AuditLog "Certificate with subject '$CertName' not removed." throw "Certificate with subject '$CertName' was not removed by user." } $VerbosePreference = 'SilentlyContinue' } else { Write-AuditLog "Certificate with subject '$CertName' does not exist in the certificate store. Continuing..." } Write-AuditLog -EndFunction } #EndRegion '.\Private\Get-TkExistingCert.ps1' 62 #Region '.\Private\Get-TkExistingSecret.ps1' -1 <# .SYNOPSIS Checks if a secret exists in the specified vault. .DESCRIPTION The Get-TkExistingSecret function checks if a secret with the specified name exists in the specified vault. It uses the Get-Secret cmdlet to retrieve the secret and returns $true if the secret exists, otherwise $false. The default vault name is 'GraphEmailAppLocalStore'. .PARAMETER AppName The name of the application for which the secret is being checked. .PARAMETER VaultName The name of the vault where the secret is stored. Defaults to 'GraphEmailAppLocalStore'. .OUTPUTS [bool] $true if the secret exists, otherwise $false. .EXAMPLE $secretExists = Get-TkExistingSecret -AppName 'MyApp' if ($secretExists) { Write-Output "Secret exists." } else { Write-Output "Secret does not exist." } .NOTES This function uses the Get-Secret cmdlet to check for the existence of a secret in the specified vault. #> function Get-TkExistingSecret { param ( [string]$AppName, [string]$VaultName = 'GraphEmailAppLocalStore' ) Write-AuditLog -BeginFunction try { $ExistingSecret = Get-Secret -Name "$AppName" -Vault $VaultName -ErrorAction SilentlyContinue if ($ExistingSecret) { return $true } else { return $false } } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Get-TkExistingSecret.ps1' 44 #Region '.\Private\Get-TkMsalToken.ps1' -1 <# .SYNOPSIS Retrieves an OAuth2 token for accessing Microsoft Graph or other APIs using various authentication methods. .DESCRIPTION The Get-TkMsalToken function supports three authentication methods: - Client Certificate - Client Secret - Managed Identity (only works in Azure-hosted environments) .PARAMETER ClientCertificate The X.509 certificate used for authentication. Example: $ClientCertificate = Get-Item Cert:\CurrentUser\My\<thumbprint> .PARAMETER ClientSecret The client secret used for authentication. .PARAMETER UseManagedIdentity Use Azure Managed Identity for authentication (only works in Azure-hosted environments). .PARAMETER ClientId The Azure AD application (client) ID. .PARAMETER TenantId The Azure AD tenant ID (GUID). .PARAMETER Scope The API scope for token access. Default is Microsoft Graph. .PARAMETER AuthorityType The authority type to use for authentication. Valid values are 'Global', 'AzureGov', and 'China'. .EXAMPLE Get-TkMsalToken -ClientCertificate $ClientCert -ClientId 'your-client-id' -TenantId 'your-tenant-id' .EXAMPLE Get-TkMsalToken -ClientSecret $ClientSecret -ClientId 'your-client-id' -TenantId 'your-tenant-id' .EXAMPLE Get-TkMsalToken -UseManagedIdentity -ClientId 'your-client-id' -TenantId 'your-tenant-id' .NOTES Author: DrIOSx Date: 2025-03-16 Version: 1.0 #> function Get-TkMsalToken { [CmdletBinding(DefaultParameterSetName = 'ClientCertificate')] [OutputType([string])] param ( # Client Certificate [Parameter( ParameterSetName = 'ClientCertificate', Mandatory = $true, HelpMessage = ` "The X.509 certificate used for authentication. Example: `n`$ClientCertificate = Get-Item Cert:\CurrentUser\My\<thumbprint>" )] [System.Security.Cryptography.X509Certificates.X509Certificate2] $ClientCertificate, # Client Secret [Parameter( ParameterSetName = 'ClientSecret', Mandatory = $true, HelpMessage = ` 'The client secret used for authentication.' )] [ValidateNotNullOrEmpty()] [SecureString] $ClientSecret, # Managed Identity [Parameter( ParameterSetName = 'ManagedIdentity', Mandatory = $true, HelpMessage = ` 'Use Azure Managed Identity for authentication (only works in Azure-hosted environments).' )] [switch] $UseManagedIdentity, # Client ID [Parameter( Mandatory = $true, HelpMessage = 'The Azure AD application (client) ID.' )] [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')] [string] $ClientId, # Tenant ID [Parameter( Mandatory = $true, HelpMessage = 'The Azure AD tenant ID (GUID).' )] [ValidatePattern('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')] [string] $TenantId, # Scope [Parameter( HelpMessage = 'The API scope for token access. Default is Microsoft Graph.' )] [ValidatePattern('^https:\/\/[a-zA-Z0-9.-]+\/[a-zA-Z0-9.-]+$')] [string] $Scope = 'https://graph.microsoft.com/.default', # Authority Type [Parameter( HelpMessage = 'The authority type to use for authentication.' )] [ValidateSet('Global', 'AzureGov', 'China')] [string] $AuthorityType = 'Global' ) begin { if (-not $script:logString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } # Define Authority URL based on selected cloud type switch ($AuthorityType) { 'Global' { $authority = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" } 'AzureGov' { $authority = "https://login.microsoftonline.us/$TenantId/oauth2/v2.0/token" } 'China' { $authority = "https://login.chinacloudapi.cn/$TenantId/oauth2/v2.0/token" } } } process { if ($PSCmdlet.ParameterSetName -eq 'ManagedIdentity') { # Managed Identity Authentication (Only Works in Azure-hosted Environments) try { $uri = 'http://169.254.169.254/metadata/identity/oauth2/token?resource=https://graph.microsoft.com&api-version=2019-08-01' $response = Invoke-RestMethod ` -Uri $uri ` -Method Get ` -Headers @{ 'Metadata' = 'true' } ` -ErrorAction Stop return $response.access_token } catch { Write-Error "Failed to obtain token via Managed Identity: $_" throw } } elseif ($PSCmdlet.ParameterSetName -eq 'ClientCertificate') { if ($ClientCertificate.NotAfter -lt (Get-Date)) { Write-Error "The provided certificate has expired on $($ClientCertificate.NotAfter). Please use a valid certificate." throw "Certificate has expired." } $jwtHeader = @{ alg = 'RS256' typ = 'JWT' x5t = [Convert]::ToBase64String($ClientCertificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '=' } $iatTime = [int](Get-Date (Get-Date).ToUniversalTime() -UFormat %s) $expTime = $iatTime + 600 # 10 min expiration $jwtPayload = @{ aud = $authority exp = $expTime iat = $iatTime nbf = $iatTime iss = $ClientId sub = $ClientId jti = [guid]::NewGuid().ToString() } $base64UrlEncode = { param ($string) [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($string)) -replace '\+', '-' -replace '/', '_' -replace '=' } $jwtHeaderEncoded = &$base64UrlEncode (ConvertTo-Json $jwtHeader -Compress) $jwtPayloadEncoded = &$base64UrlEncode (ConvertTo-Json $jwtPayload -Compress) $jwtToSign = "$jwtHeaderEncoded.$jwtPayloadEncoded" try { $csp = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($ClientCertificate) $signature = [Convert]::ToBase64String( $csp.SignData( [System.Text.Encoding]::UTF8.GetBytes($jwtToSign), [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) ) -replace '\+', '-' -replace '/', '_' -replace '=' } catch { Write-Error "Failed to sign JWT: $_" throw } $clientAssertion = "$jwtToSign.$signature" $body = @{ client_id = $ClientId client_assertion = $clientAssertion client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' grant_type = 'client_credentials' scope = $Scope } } elseif ($PSCmdlet.ParameterSetName -eq 'ClientSecret') { $plainClientSecret = ConvertFrom-SecureString -SecureString $ClientSecret -AsPlainText $body = @{ client_id = $ClientId client_secret = $plainClientSecret grant_type = 'client_credentials' scope = $Scope } } } end { try { Write-AuditLog "Requesting token from $authority." $tokenResponse = (Invoke-RestMethod -Method Post -Uri $authority -ContentType 'application/x-www-form-urlencoded' -Body $body -ErrorAction Stop).access_token Write-AuditLog "Successfully obtained token from $authority." Write-AuditLog -EndFunction return $tokenResponse } catch { Write-AuditLog -Message "Failed to obtain token: $($_.Exception.Message)" -Severity "Error" throw } } } #EndRegion '.\Private\Get-TkMsalToken.ps1' 201 #Region '.\Private\Initialize-TkAppAuthCertificate.ps1' -1 <# .SYNOPSIS Initializes or retrieves an authentication certificate for the TkApp. .DESCRIPTION The Initialize-TkAppAuthCertificate function either retrieves an existing certificate by thumbprint or creates a new self-signed certificate if no thumbprint is provided. The function logs the process and supports ShouldProcess for confirmation prompts. .PARAMETER Thumbprint The thumbprint of the certificate to retrieve. If omitted, a new self-signed certificate is created. .PARAMETER AppName An optional name to store in the output object (e.g., the associated app name). .PARAMETER Subject The subject name for the new certificate if no thumbprint is provided. Default is 'CN=TkDefaultSelfSignedCert'. .PARAMETER CertStoreLocation The certificate store location (e.g., "Cert:\CurrentUser\My"). Default is 'Cert:\CurrentUser\My'. .PARAMETER KeyExportPolicy Exportable key policy for the new certificate. Valid values are 'Exportable' and 'NonExportable'. Default is 'NonExportable'. .OUTPUTS PSCustomObject An object containing the certificate thumbprint and expiration date. If AppName is provided, it is included in the output object. .EXAMPLE Initialize-TkAppAuthCertificate -Thumbprint 'ABC123DEF456' .EXAMPLE Initialize-TkAppAuthCertificate -Subject 'CN=MyAppCert' -AppName 'MyApp' .NOTES This function requires the user to have appropriate permissions to access the certificate store and create certificates. #> function Initialize-TkAppAuthCertificate { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter( Mandatory = $false, HelpMessage = 'The thumbprint of the certificate to retrieve. If omitted, a new self-signed certificate is created.' )] [string] $Thumbprint, [Parameter( Mandatory = $false, HelpMessage = 'An optional name to store in the output object (e.g., the associated app name).' )] [string] $AppName, [Parameter( Mandatory = $false, HelpMessage = 'The subject name for the new certificate if no thumbprint is provided.' )] [string] $Subject = 'CN=TkDefaultSelfSignedCert', [Parameter( Mandatory = $false, HelpMessage = 'The certificate store location (e.g., "Cert:\CurrentUser\My").' )] [string] $CertStoreLocation = 'Cert:\CurrentUser\My', [Parameter( Mandatory = $false, HelpMessage = 'Exportable key policy for the new certificate.' )] [ValidateSet('Exportable', 'NonExportable')] [string] $KeyExportPolicy = 'NonExportable' ) if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' try { if ($Thumbprint) { # Retrieve an existing certificate $cert = Get-ChildItem -Path $CertStoreLocation | Where-Object { $_.Thumbprint -eq $Thumbprint } if (-not $cert) { throw "Certificate with thumbprint $Thumbprint not found in $CertStoreLocation." } Write-AuditLog "Retrieved certificate with thumbprint $Thumbprint from $CertStoreLocation." } else { # Prompt before creating a new certificate $shouldProcessTarget = "'Subject:$Subject' in $CertStoreLocation" $shouldProcessOperation = 'New-SelfSignedCertificate' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { Get-TkExistingCert ` -CertName $Subject ` -ErrorAction Stop $cert = New-SelfSignedCertificate -Subject $Subject -CertStoreLocation $CertStoreLocation ` -KeyExportPolicy $KeyExportPolicy -KeySpec Signature -KeyLength 2048 -KeyAlgorithm RSA -HashAlgorithm SHA256 Write-AuditLog "Created new self-signed certificate with subject '$Subject' in $CertStoreLocation." } else { Write-AuditLog "Certificate creation was skipped by user confirmation." throw 'Certificate creation was skipped by user confirmation.' } } $output = [PSCustomObject]@{ CertThumbprint = $cert.Thumbprint CertExpires = $cert.NotAfter.ToString('yyyy-MM-dd HH:mm:ss') } if ($AppName) { $output | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $AppName } return $output } catch { Write-AuditLog -Message "Error occurred: $($_.Exception.Message)" -Severity "Error" throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Initialize-TkAppAuthCertificate.ps1' 111 #Region '.\Private\Initialize-TkAppName.ps1' -1 <# .SYNOPSIS Generates a new application name based on provided prefix, scenario name, and user email. .DESCRIPTION The Initialize-TkAppName function constructs an application name using a specified prefix, an optional scenario name, and an optional user email. The generated name includes a domain suffix derived from the environment variable USERDNSDOMAIN. .PARAMETER Prefix A short prefix for your app name (2-4 alphanumeric characters). This parameter is mandatory. .PARAMETER ScenarioName An optional scenario name to include in the app name (for example, AuditGraphEmail, MemPolicy, etc.). Defaults to "TkEmailApp". .PARAMETER UserId An optional user email to append an "As-[username]" suffix to the app name. The email must be provided in a valid format. .PARAMETER DoNotUseDomainSuffix A switch to add a session domain suffix to the app name. If not specified, the domain suffix is derived from the USERDNSDOMAIN environment variable. .INPUTS System.String .OUTPUTS System.String .EXAMPLE PS> Initialize-TkAppName -Prefix "MSN" Generates an app name with the prefix "MSN" and default scenario name "TkEmailApp". .EXAMPLE PS> Initialize-TkAppName -Prefix "MSN" -ScenarioName "AuditGraphEmail" Generates an app name with the prefix "MSN" and scenario name "AuditGraphEmail". .EXAMPLE PS> Initialize-TkAppName -Prefix "MSN" -UserId "helpdesk@mydomain.com" Generates an app name with the prefix "MSN" and appends the user suffix derived from the email "helpdesk@mydomain.com". .NOTES The function logs the process of building the app name and handles errors by logging and throwing them. #> function Initialize-TkAppName { [CmdletBinding()] [OutputType([string])] param( [Parameter( Mandatory=$true, HelpMessage='A short prefix for your app name (2-4 alphanumeric chars).' )] [ValidatePattern('^[A-Z0-9]{2,4}$')] [string] $Prefix, [Parameter( Mandatory=$false, HelpMessage='Optional scenario name (e.g. AuditGraphEmail, MemPolicy, etc.).' )] [string] $ScenarioName = "TkEmailApp", [Parameter( Mandatory=$false, HelpMessage='Optional user email to append "As-[username]" suffix.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,}$')] [string] $UserId, [Parameter( Mandatory=$false, HelpMessage='Switch to add session domain suffix to the app name.' )] [switch] $DoNotUseDomainSuffix ) begin { if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } } process { try { Write-AuditLog "Building app name..." # Build a user suffix if $UserId is provided $userSuffix = "" if ($UserId) { # e.g. "helpdesk@mydomain.com" -> "As-helpDesk" $userPrefix = ($UserId.Split('@')[0]) $userSuffix = "-As-$userPrefix" } if ($DoNotUseDomainSuffix) { $domainSuffix = "MyDomain" } else { $domainSuffix = $env:USERDNSDOMAIN } $appName = "GraphToolKit-$Prefix-$ScenarioName" Write-AuditLog "Returning app name: $appName (Prefix: $Prefix, Scenario: $ScenarioName, User Suffix: $userSuffix)" $appName += "-$domainSuffix" $appName += "$userSuffix" Write-AuditLog "Returning app name: $appName" return $appName } catch { $errorMessage = "An error occurred while building the app name: $_" Write-AuditLog -Message $errorMessage -Severity "Error" # include error severity per StyleGuide throw $errorMessage } finally { Write-AuditLog -EndFunction } } } #EndRegion '.\Private\Initialize-TkAppName.ps1' 97 #Region '.\Private\Initialize-TkEmailAppParamsObject.ps1' -1 <# .SYNOPSIS Initializes a TkEmailAppParams object with the provided parameters. .DESCRIPTION The Initialize-TkEmailAppParamsObject function creates and returns a new instance of the TkEmailAppParams class using the provided parameters. This function ensures that all necessary parameters are provided and initializes the object accordingly. .PARAMETER AppId The application ID used to uniquely identify the email application. .PARAMETER Id The unique identifier for the specific email application instance. .PARAMETER AppName The name of the email application being initialized. .PARAMETER CertificateSubject The subject name of the client certificate used by the email application. .PARAMETER AppRestrictedSendGroup The group that is restricted from sending emails within the application. .PARAMETER CertExpires The expiration date of the certificate used by the email application. .PARAMETER CertThumbprint The thumbprint of the certificate used for authentication. .PARAMETER ConsentUrl The URL where users can provide consent for the email application. .PARAMETER DefaultDomain The default domain used by the email application for sending emails. .PARAMETER SendAsUser The user who will send emails on behalf of the email application. .PARAMETER SendAsUserEmail The email address of the user who will send emails on behalf of the application. .PARAMETER TenantID The tenant ID associated with the email application. .OUTPUTS [TkEmailAppParams] Returns a new instance of the TkEmailAppParams class initialized with the provided parameters. .EXAMPLE $tkEmailAppParams = Initialize-TkEmailAppParamsObject -AppId "12345" -Id "67890" -AppName "MyEmailApp" -CertificateSubject "CN=MyCert" -AppRestrictedSendGroup "RestrictedGroup" -CertExpires "2023-12-31" -CertThumbprint "ABCDEF123456" -ConsentUrl "https://consent.url" -DefaultDomain "example.com" -SendAsUser "user@example.com" -SendAsUserEmail "user@example.com" -TenantID "tenant123" This example initializes a TkEmailAppParams object with the specified parameters. #> function Initialize-TkEmailAppParamsObject { param ( [string]$AppId, [string]$Id, [string]$AppName, [string]$CertificateSubject, [string]$AppRestrictedSendGroup, [string]$CertExpires, [string]$CertThumbprint, [string]$ConsentUrl, [string]$DefaultDomain, [string]$SendAsUser, [string]$SendAsUserEmail, [string]$TenantID ) return [TkEmailAppParams]::new( $AppId, $Id, $AppName, $CertificateSubject, $AppRestrictedSendGroup, $CertExpires, $CertThumbprint, $ConsentUrl, $DefaultDomain, $SendAsUser, $SendAsUserEmail, $TenantID ) } #EndRegion '.\Private\Initialize-TkEmailAppParamsObject.ps1' 68 #Region '.\Private\Initialize-TkM365AuditAppParamsObject.ps1' -1 <# .SYNOPSIS Initializes a TkM365AuditAppParams object with the provided parameters. .DESCRIPTION This function initializes a TkM365AuditAppParams object using the parameters provided by the user. It sets up the application name, application ID, object ID, tenant ID, certificate thumbprint, certificate expiration date, consent URL, and various permissions for Microsoft Graph, SharePoint, and Exchange. This allows for the configuration and management of the TkM365AuditAppParams object within the application. .PARAMETER AppName The name of the application. .PARAMETER AppId The unique identifier for the application. .PARAMETER ObjectId The unique identifier for the object. .PARAMETER TenantId The unique identifier for the tenant. .PARAMETER CertThumbprint The thumbprint of the certificate used. .PARAMETER CertExpires The expiration date of the certificate. .PARAMETER ConsentUrl The URL used for consent. .PARAMETER MgGraphPermissions An array of permissions for Microsoft Graph. .PARAMETER SharePointPermissions An array of permissions for SharePoint. .PARAMETER ExchangePermissions An array of permissions for Exchange. .OUTPUTS TkM365AuditAppParams A new instance of the TkM365AuditAppParams object initialized with the provided parameters. .EXAMPLE $Params = Initialize-TkM365AuditAppParamsObject -AppName "MyApp" -AppId "12345" -ObjectId "67890" -TenantId "tenant123" -CertThumbprint "ABCDEF" -CertExpires "2023-12-31" -ConsentUrl "https://consent.url" -MgGraphPermissions @("Permission1", "Permission2") -SharePointPermissions @("Permission1") -ExchangePermissions @("Permission1", "Permission2") #> function Initialize-TkM365AuditAppParamsObject { param ( [string]$AppName, [string]$AppId, [string]$ObjectId, [string]$TenantId, [string]$CertThumbprint, [string]$CertExpires, [string]$ConsentUrl, [string[]]$MgGraphPermissions, [string[]]$SharePointPermissions, [string[]]$ExchangePermissions ) return [TkM365AuditAppParams]::new( $AppName, $AppId, $ObjectId, $TenantId, $CertThumbprint, $CertExpires, $ConsentUrl, $MgGraphPermissions, $SharePointPermissions, $ExchangePermissions ) } #EndRegion '.\Private\Initialize-TkM365AuditAppParamsObject.ps1' 58 #Region '.\Private\Initialize-TkMemPolicyManagerAppParamsObject.ps1' -1 <# .SYNOPSIS Initializes a TkMemPolicyManagerAppParams object with the provided parameters. .DESCRIPTION This function creates and returns a new instance of the TkMemPolicyManagerAppParams class using the provided parameters. .PARAMETER AppId The unique identifier for the application. .PARAMETER AppName The name of the application to be initialized. .PARAMETER CertThumbprint The thumbprint of the certificate used for authentication. .PARAMETER ObjectId The unique identifier for the object. .PARAMETER ConsentUrl The URL where consent can be granted for the application. .PARAMETER PermissionSet The set of permissions required by the application. .PARAMETER Permissions The specific permissions granted to the application. .PARAMETER TenantId The unique identifier for the tenant. .OUTPUTS [TkMemPolicyManagerAppParams] The initialized TkMemPolicyManagerAppParams object. .EXAMPLE $AppParams = Initialize-TkMemPolicyManagerAppParamsObject -AppId "12345" -AppName "MyApp" -CertThumbprint "ABCDEF" -ObjectId "67890" -ConsentUrl "https://consent.url" -PermissionSet "ReadWrite" -Permissions "All" -TenantId "Tenant123" #> function Initialize-TkMemPolicyManagerAppParamsObject { param ( [string]$AppId, [string]$AppName, [string]$CertThumbprint, [string]$ObjectId, [string]$ConsentUrl, [string]$PermissionSet, [string]$Permissions, [string]$TenantId ) return [TkMemPolicyManagerAppParams]::new( $AppId, $AppName, $CertThumbprint, $ObjectId, $ConsentUrl, $PermissionSet, $Permissions, $TenantId ) } #EndRegion '.\Private\Initialize-TkMemPolicyManagerAppParamsObject.ps1' 49 #Region '.\Private\Initialize-TkModuleEnv.ps1' -1 <# .SYNOPSIS Initializes the environment by installing and importing specified PowerShell modules. .DESCRIPTION The Initialize-TkModuleEnv function installs and imports specified PowerShell modules, either public or pre-release versions, based on the provided parameters. It also ensures that the PowerShellGet module is up-to-date and handles the installation scope, requiring elevation for 'AllUsers' scope. The function logs the installation and import process using Write-AuditLog. .PARAMETER PublicModuleNames An array of public module names to be installed and imported from the PowerShell Gallery. Each module must exist in the gallery. .PARAMETER PublicRequiredVersions An array of required versions corresponding to the public module names. Must match the count of PublicModuleNames. .PARAMETER PrereleaseModuleNames An array of pre-release module names to be installed from the PowerShell Gallery. Used for modules in preview/beta state. .PARAMETER PrereleaseRequiredVersions An array of required versions corresponding to the pre-release module names. Must match the count of PrereleaseModuleNames. .PARAMETER Scope The installation scope, either 'AllUsers' (requires elevation) or 'CurrentUser' (default, no elevation needed). .PARAMETER ImportModuleNames An optional array of module names to be imported after installation. Useful for importing specific modules from a larger package. .INPUTS None. This function does not accept pipeline input. .OUTPUTS None. This function does not generate output. .EXAMPLE $params1 = @{ PublicModuleNames = "PSnmap","Microsoft.Graph" PublicRequiredVersions = "1.3.1","1.23.0" ImportModuleNames = "Microsoft.Graph.Authentication", "Microsoft.Graph.Identity.SignIns" Scope = "CurrentUser" } Initialize-TkModuleEnv @params1 Installs and imports specific modules for Microsoft.Graph. .EXAMPLE $params2 = @{ PrereleaseModuleNames = "Sampler", "Pester" PrereleaseRequiredVersions = "2.1.5", "4.10.1" Scope = "CurrentUser" } Initialize-TkModuleEnv @params2 Installs the pre-release versions of Sampler and Pester in the CurrentUser scope. .NOTES - If Microsoft.Graph is being installed, the function limit is raised to 8192 if it is less than that. - The function checks and updates PowerShellGet if needed. - The function validates the installation scope and requires elevation for 'AllUsers' scope. - The function logs the installation and import process using Write-AuditLog. #> function Initialize-TkModuleEnv { [CmdletBinding(DefaultParameterSetName = 'Public')] param( [Parameter( ParameterSetName = 'Public', Mandatory, HelpMessage = 'Array of public module names to be installed from the PowerShell Gallery' )] [string[]] $PublicModuleNames, [Parameter( ParameterSetName = 'Public', Mandatory, HelpMessage = 'Array of required versions corresponding to the public module names' )] [string[]] $PublicRequiredVersions, [Parameter( ParameterSetName = 'Prerelease', Mandatory, HelpMessage = 'Array of pre-release module names to be installed from the PowerShell Gallery' )] [string[]] $PrereleaseModuleNames, [Parameter( ParameterSetName = 'Prerelease', Mandatory, HelpMessage = 'Array of required versions corresponding to the pre-release module names' )] [string[]] $PrereleaseRequiredVersions, [Parameter( HelpMessage = 'Installation scope, either AllUsers (requires admin) or CurrentUser' )] [ValidateSet('AllUsers', 'CurrentUser')] [string] $Scope, [Parameter( HelpMessage = 'Optional array of module names to import after installation (useful for submodules)' )] [string[]] $ImportModuleNames = $null ) if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###########################################################' try { # If Microsoft.Graph is being installed, raise function limit if < 8192. if (($PublicModuleNames -match 'Microsoft.Graph') -or ($PrereleaseModuleNames -match 'Microsoft.Graph')) { if ($script:MaximumFunctionCount -lt 8192) { $script:MaximumFunctionCount = 8192 Write-AuditLog "Increased maximum function count to $script:MaximumFunctionCount for Microsoft.Graph" -Severity Information } } # Step 1: Check/Update PowerShellGet if needed $psGetModules = Get-Module -Name PowerShellGet -ListAvailable $hasNonDefaultVer = $false foreach ($mod in $psGetModules) { if ($mod.Version -ne '1.0.0.1') { $hasNonDefaultVer = $true break } } if ($hasNonDefaultVer) { # Import the latest version $latestModule = $psGetModules | Sort-Object Version -Descending | Select-Object -First 1 Import-Module -Name $latestModule.Name -RequiredVersion $latestModule.Version -ErrorAction Stop Write-AuditLog "Imported PowerShellGet version $($latestModule.Version)" -Severity Information } else { if (-not(Test-IsAdmin)) { Write-AuditLog 'PowerShellGet is version 1.0.0.1. Please run once as admin to update PowerShellGet.' -Severity Error throw 'Elevation required to update PowerShellGet!' } else { Write-AuditLog 'Updating PowerShellGet...' -Severity Information [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Install-Module PowerShellGet -AllowClobber -Force -ErrorAction Stop $psGetModules = Get-Module -Name PowerShellGet -ListAvailable $latestModule = $psGetModules | Sort-Object Version -Descending | Select-Object -First 1 Import-Module -Name $latestModule.Name -RequiredVersion $latestModule.Version -ErrorAction Stop Write-AuditLog "Updated and imported PowerShellGet version $($latestModule.Version)" -Severity Information } } # Step 2: Validate scope if ($Scope -eq 'AllUsers') { if (-not(Test-IsAdmin)) { Write-AuditLog "You must be an administrator to install in 'AllUsers' scope." -Severity Error throw "Elevation required for 'AllUsers' scope." } else { Write-AuditLog "Installing modules for 'AllUsers' scope." -Severity Information } } # Step 3: Determine module set $prerelease = $false if ($PSCmdlet.ParameterSetName -eq 'Public') { $modules = $PublicModuleNames $versions = $PublicRequiredVersions } elseif ($PSCmdlet.ParameterSetName -eq 'Prerelease') { $modules = $PrereleaseModuleNames $versions = $PrereleaseRequiredVersions $prerelease = $true } # Step 4: Install/Import each module for ($i = 0; $i -lt $modules.Count; $i++) { $m = $modules[$i] $requiredVersion = $versions[$i] # Using index instead of IndexOf for reliability $installed = Get-Module -Name $m -ListAvailable | Where-Object { [version]$_.Version -ge [version]$requiredVersion } | Sort-Object Version -Descending | Select-Object -First 1 $SelectiveImports = $null if ($ImportModuleNames) { $SelectiveImports = $ImportModuleNames | Where-Object { $_ -match $m } } if (-not $installed) { $msgPrefix = if ($prerelease) { 'PreRelease' }else { 'stable' } Write-AuditLog "The $msgPrefix module $m version $requiredVersion (or higher) is not installed." -Severity Warning Write-AuditLog "Installing $m version $requiredVersion -AllowPrerelease:$prerelease." try { Install-Module $m -Scope $Scope -RequiredVersion $requiredVersion -AllowPrerelease:$prerelease -ErrorAction Stop Write-AuditLog "$m module successfully installed!" -Severity Information } catch { Write-AuditLog "Failed to install $m v$requiredVersion`: $(${($_.Exception.Message)})" -Severity Error throw } if ($SelectiveImports) { foreach ($ModName in $SelectiveImports) { Write-AuditLog "Importing $ModName." try { Import-Module $ModName -ErrorAction Stop Write-AuditLog "Successfully imported $ModName." -Severity Information } catch { Write-AuditLog "Failed to import $ModName`: $($_.Exception.Message)" -Severity Error throw } } } else { Write-AuditLog "Importing $m" try { Import-Module $m -ErrorAction Stop Write-AuditLog "Successfully imported $m" -Severity Information } catch { Write-AuditLog "Failed to import $m`: $($_.Exception.Message)" -Severity Error throw } } } else { Write-AuditLog "$m v$($installed.Version) exists." -Severity Information if ($SelectiveImports) { foreach ($ModName in $SelectiveImports) { Write-AuditLog "Importing SubModule: $ModName." try { Import-Module $ModName -ErrorAction Stop Write-AuditLog "Imported SubModule: $ModName." -Severity Information } catch { Write-AuditLog "Failed to import submodule $ModName`: $($_.Exception.Message)" -Severity Error throw } } } else { Write-AuditLog "Importing $m" try { Import-Module $m -ErrorAction Stop Write-AuditLog "Imported $m" -Severity Information } catch { Write-AuditLog "Failed to import $m`: $($_.Exception.Message)" -Severity Error throw } } } } } catch { Write-AuditLog "Module initialization failed: $($_.Exception.Message)" -Severity Error throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Initialize-TkModuleEnv.ps1' 256 #Region '.\Private\Initialize-TkRequiredResourcePermissionObject.ps1' -1 <# .SYNOPSIS Creates a new required resource permission object for Microsoft Graph and specific scenarios. .DESCRIPTION The Initialize-TkRequiredResourcePermissionObject function creates a new required resource permission object for Microsoft Graph and specific scenarios. It retrieves service principals by display name, builds an array of MicrosoftGraphRequiredResourceAccess objects, and processes application permissions and scenario-specific permissions. .PARAMETER GraphPermissions Specifies an array of application (app-only) permissions for Microsoft Graph. Defaults to 'Mail.Send'. This parameter supports multiple permissions. .PARAMETER Scenario Specifies the scenario for which to include additional permissions. Currently supports '365Audit'. .INPUTS None .OUTPUTS [PSCustomObject] containing the RequiredResourceAccessList. .EXAMPLE PS C:\> Initialize-TkRequiredResourcePermissionObject -GraphPermissions 'User.Read', 'Mail.Send' Creates a required resource permission object with the specified Graph permissions. .EXAMPLE PS C:\> Initialize-TkRequiredResourcePermissionObject -Scenario '365Audit' Creates a required resource permission object for the '365Audit' scenario, including specific SharePoint and Exchange permissions. .NOTES Author: DougRios | GraphAppToolkit Module Last Updated: 2025-03-16 This function requires the Microsoft.Graph PowerShell module. #> function Initialize-TkRequiredResourcePermissionObject { [CmdletBinding(DefaultParameterSetName = 'Default')] param ( [Parameter( Mandatory = $false, HelpMessage = 'Specifies an array of application (app-only) permissions for Microsoft Graph. Defaults to ''Mail.Send''. This parameter supports multiple permissions.' )] [string[]] $GraphPermissions = @('Mail.Send'), [Parameter( Mandatory = $false, HelpMessage = 'Specifies the scenario for which to include additional permissions. Currently supports ''365Audit''.', ParameterSetName = 'Scenario' )] [ValidateSet('365Audit')] [string] $Scenario ) process { # Start logging if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { Write-AuditLog '###############################################' # 1) Retrieve service principals by DisplayName Write-AuditLog 'Looking up service principals by display name...' $spGraph = Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'" -ErrorAction Stop # 2) Build an array of [MicrosoftGraphRequiredResourceAccess] objects $requiredResourceAccessList = @() # Retrieve all application permissions $permissionList = Find-MgGraphPermission -PermissionType Application -All # region Graph perms [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $graphRra = $null # If GraphPermissions is not null or empty, process them if ($GraphPermissions -and $GraphPermissions.Count -gt 0) { if (-not $spGraph) { $errorMessage = 'Microsoft Graph Service Principal not found (by display name).' Write-AuditLog -Message $errorMessage -Severity Error throw $errorMessage } Write-AuditLog "Gathering permissions: $($GraphPermissions -join ', ')" $graphRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new() $graphRra.ResourceAppId = $spGraph.AppId foreach ($permName in $GraphPermissions) { $foundPerm = $permissionList | Where-Object { $_.Name -eq $permName } if ($foundPerm) { # If multiple matches, pick the first $graphRra.ResourceAccess += @{ Id = $foundPerm.Id; Type = 'Role' } Write-AuditLog "Found Graph permission ID for '$permName': $($foundPerm[0].Id)" } else { Write-AuditLog -Severity Warning -Message "Graph Permission '$permName' not found!" } } if ($graphRra.ResourceAccess) { $requiredResourceAccessList += $graphRra } else { $errorMessage = "No Graph permissions found for '$($GraphPermissions -join ', ')'. Check the permission names and try again." Write-AuditLog -Message $errorMessage -Severity Error throw $errorMessage } } # endregion # region Scenario-specific permissions # Scenario 365Audit if ($Scenario -eq '365Audit') { # region SharePoint perms [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $spRra = $null $spRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new() $spRra.ResourceAppId = "00000003-0000-0ff1-ce00-000000000000" # SharePoint Online $spRra.ResourceAccess += @{ Id = 'd13f72ca-a275-4b96-b789-48ebcc4da984'; Type = 'Role' } $spRra.ResourceAccess += @{ Id = '678536fe-1083-478a-9c59-b99265e6b0d3'; Type = 'Role' } $requiredResourceAccessList += $spRra # endregion # region Exchange perms [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess] $exRra = $null $exRra = [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess]::new() $exRra.ResourceAppId = "00000002-0000-0ff1-ce00-000000000000" # Exchange Online $exRra.ResourceAccess += @{ Id = 'dc50a0fb-09a3-484d-be87-e023b12c6440'; Type = 'Role' } $requiredResourceAccessList += $exRra } # endregion Scenario 365Audit # endregion Scenario-specific permissions # 3) Build final result object $result = [PSCustomObject]@{ RequiredResourceAccessList = $requiredResourceAccessList } Write-AuditLog 'Returning context object.' return $result } catch { Write-AuditLog -Message "An error occurred: $($_.Exception.Message)" -Severity Error throw } finally { Write-AuditLog -EndFunction } } } #EndRegion '.\Private\Initialize-TkRequiredResourcePermissionObject.ps1' 130 #Region '.\Private\New-TkAppRegistration.ps1' -1 <# .SYNOPSIS Creates a new enterprise app registration in Azure AD. .DESCRIPTION The New-TkAppRegistration function creates a new enterprise app registration in Azure AD using the provided display name, certificate thumbprint, and additional optional parameters such as required resource access list, sign-in audience, certificate store location, and descriptive notes about this app's purpose or usage. .PARAMETER DisplayName The display name for the new app registration, which must be clearly defined and descriptive. .PARAMETER RequiredResourceAccessList An array of MicrosoftGraphRequiredResourceAccess objects to configure multi-resource access modes securely. .PARAMETER SignInAudience The sign-in audience for the app registration. Valid values include 'AzureADMyOrg', 'AzureADMultipleOrgs', and 'AzureADandPersonalMicrosoftAccount'. .PARAMETER CertThumbprint The thumbprint of the certificate used to secure this app registration, ensuring the certificate is valid and present. .PARAMETER CertStoreLocation The certificate store location, for example "Cert:\CurrentUser\My", where the certificate is located. .PARAMETER Notes A descriptive note about this app's purpose or usage to provide context and clarity. .INPUTS None. .OUTPUTS [Microsoft.Graph.PowerShell.Models.MicrosoftGraphApplication1] representing the newly created app registration. .EXAMPLE $AppRegistration = New-TkAppRegistration -DisplayName "MyApp" -CertThumbprint "ABC123" -Notes "This is a sample app registration for enterprise use." .NOTES This function requires the Microsoft.Graph PowerShell module. Required permissions: - Application.ReadWrite.All - Directory.ReadWrite.All #> function New-TkAppRegistration { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([Microsoft.Graph.PowerShell.Models.MicrosoftGraphApplication1])] param ( [Parameter( Mandatory = $true, HelpMessage = ` 'The display name for the new app registration.' )] [string] $DisplayName, [Parameter( Mandatory = $false, HelpMessage = ` 'An array of MicrosoftGraphRequiredResourceAccess objects to configure multi-resource access modes securely.' )] [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess[]] $RequiredResourceAccessList, [Parameter( HelpMessage = ` 'The sign-in audience for the app registration. Valid values include ''AzureADMyOrg'', ''AzureADMultipleOrgs'', and ''AzureADandPersonalMicrosoftAccount''.' )] [ValidateSet('AzureADMyOrg', 'AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount')] [string] $SignInAudience = 'AzureADMyOrg', [Parameter( Mandatory = $true, HelpMessage = ` 'The thumbprint of the certificate used to secure this app registration, ensuring the certificate is valid and present.' )] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = ` 'The certificate store location (e.g., "Cert:\CurrentUser\My") where the certificate is located.' )] [string] $CertStoreLocation = 'Cert:\CurrentUser\My', [Parameter( Mandatory = $false, HelpMessage = ` 'A descriptive note about this app''s purpose or usage to provide context and clarity.' )] [string] $Notes ) # Begin Logging if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' try { Write-AuditLog "Creating new enterprise app registration for '$DisplayName'." if ($CertThumbprint) { # 1) Retrieve the certificate from the CurrentUser store $cert = Get-ChildItem -Path $CertStoreLocation | Where-Object { $_.Thumbprint -eq $CertThumbprint } if (-not $cert) { throw "Certificate with thumbprint $CertThumbprint not found in $CertStoreLocation." } $shouldProcessTarget = "'$DisplayName' for sign-in audience '$SignInAudience' with certificate thumbprint $CertThumbprint." $shouldProcessOperation = 'New-MgApplication' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { $MgApplicationParams = @{ DisplayName = $DisplayName Notes = $Notes SignInAudience = $SignInAudience RequiredResourceAccess = $RequiredResourceAccessList AdditionalProperties = @{} KeyCredentials = @( @{ Type = 'AsymmetricX509Cert' Usage = 'Verify' Key = $Cert.RawData } ) Web = @{ RedirectUris = @('https://login.microsoftonline.com/common/oauth2/nativeclient') } } $appRegistration = New-MgApplication @MgApplicationParams } if (-not $appRegistration) { throw "The app creation failed for '$DisplayName'." } Write-AuditLog "App registration created with app Object ID $($appRegistration.Id)." return $appRegistration } else { throw 'CertThumbprint is required to create an app registration. No other methods are supported yet.' } } catch { throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\New-TkAppRegistration.ps1' 130 #Region '.\Private\New-TkAppSpOauth2Registration.ps1' -1 <# .SYNOPSIS Initializes the Service Principal registration for a given application. .DESCRIPTION This function sets up the Service Principal registration for an application in Azure AD. It supports certificate-based authentication and grants OAuth2 permissions to the Service Principal. .PARAMETER AppRegistration The App Registration object containing various properties. .PARAMETER RequiredResourceAccessList The list of required resource access for the Service Principal. .PARAMETER Context The Microsoft Graph context that we are currently in. .PARAMETER Scopes One or more OAuth2 scopes to grant. Defaults to Mail.Send. .PARAMETER AuthMethod Authentication method to use. Valid values are 'Certificate', 'ClientSecret', 'ManagedIdentity', 'None'. .PARAMETER CertThumbprint Certificate thumbprint if using Certificate-based authentication. .PARAMETER CertStoreLocation The certificate store location (e.g., "Cert:\CurrentUser\My"). Defaults to 'Cert:\CurrentUser\My'. .EXAMPLE $appRegistration = Get-MgApplication -AppId "your-app-id" $requiredResourceAccessList = @() $context = [PSCustomObject]@{ TenantId = "your-tenant-id" } New-TkAppSpOauth2Registration -AppRegistration $appRegistration -RequiredResourceAccessList $requiredResourceAccessList -Context $context .NOTES This function requires the Microsoft.Graph PowerShell module. #> function New-TkAppSpOauth2Registration { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter( Mandatory = $true, HelpMessage = 'The App Registration object containing various properties.' )] [Microsoft.Graph.PowerShell.Models.IMicrosoftGraphApplication] $AppRegistration, [Parameter( Mandatory = $true, HelpMessage = 'The list of required resource access for the Service Principal.' )] [PSCustomObject[]] $RequiredResourceAccessList, [Parameter( Mandatory = $true, HelpMessage = 'The Microsoft Graph context that we are currently in.' )] [PSCustomObject] $Context, [Parameter( Mandatory = $false, HelpMessage = 'One or more OAuth2 scopes to grant. Defaults to Mail.Send.' )] [psobject[]] $Scopes = [PSCustomObject]@{ Graph = @('Mail.Send') }, [Parameter( Mandatory = $false, HelpMessage = 'Authentication method to use. Valid values are "Certificate", "ClientSecret", "ManagedIdentity", "None".' )] [ValidateSet('Certificate', 'ClientSecret', 'ManagedIdentity', 'None')] [string] $AuthMethod = 'Certificate', [Parameter( Mandatory = $false, HelpMessage = 'Certificate thumbprint if using Certificate-based authentication.' )] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = 'The certificate store location (e.g., "Cert:\CurrentUser\My"). Defaults to "Cert:\CurrentUser\My".' )] [string] $CertStoreLocation = 'Cert:\CurrentUser\My' ) begin { if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' if ($AuthMethod -eq 'Certificate' -and -not $CertThumbprint) { throw "CertThumbprint is required when AuthMethod is 'Certificate'." } $cert = $null } process { try { # 1. If using certificate auth, retrieve the certificate if ($AuthMethod -eq 'Certificate') { Write-AuditLog "Retrieving certificate with thumbprint $CertThumbprint." $cert = Get-ChildItem -Path $CertStoreLocation | Where-Object { $_.Thumbprint -eq $CertThumbprint } if (-not $cert) { throw "Certificate with thumbprint $CertThumbprint not found in $CertStoreLocation." } } $shouldProcessTarget = "'$($AppRegistration.DisplayName)' for tenant $($Context.TenantId)." $shouldProcessOperation = 'New-MgServicePrincipal' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { # 2. Create a Service Principal for the app (if not existing). Write-AuditLog "Creating service principal for app with AppId $($AppRegistration.AppId)." [void](New-MgServicePrincipal -AppId $AppRegistration.AppId -AdditionalProperties @{}) } # 3. Get the client Service Principal for the created app. $clientSp = Get-MgServicePrincipal -Filter "appId eq '$($AppRegistration.AppId)'" if (-not $clientSp) { Write-AuditLog "Client service principal not found for $($AppRegistration.AppId)." -Severity Error throw 'Unable to find client service principal.' } $shouldProcessTarget = "'$($clientSp.DisplayName)' requested scopes for tenant $($Context.TenantId)." $shouldProcessOperation = 'New-MgOauth2PermissionGrant' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { $i = 0 foreach ($resource in $RequiredResourceAccessList) { # 4. Combine all scopes into a single space-delimited string switch ($i) { 0 { $scopesList = $Scopes.Graph $resourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Microsoft Graph'").Id } 1 { $scopesList = $Scopes.SharePoint $resourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Office 365 SharePoint Online'").Id } 2 { $scopesList = $Scopes.Exchange $resourceId = (Get-MgServicePrincipal -Filter "DisplayName eq 'Office 365 Exchange Online'").Id } ($i > 2) { throw 'Too many resources in RequiredResourceAccessList.' } Default { Write-AuditLog "No scopes found for $resource." } } $combinedScopes = $scopesList -join ' ' # Foreach resource id start Write-AuditLog "Granting the following scope(s) to Service Principal for: $($clientSp.DisplayName): $combinedScopes" $mgOauth2PermissionGrantParams = @{ ClientId = $clientSp.Id ConsentType = 'AllPrincipals' ResourceId = $resourceId Scope = $combinedScopes } [void](New-MgOauth2PermissionGrant -BodyParameter $mgOauth2PermissionGrantParams -Confirm:$false -ErrorAction Stop) Write-AuditLog "Admin consent granted for $resourceId with scopes: $combinedScopes." Start-Sleep -Seconds 2 $i++ } } $redirectUri = "&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient" # 5. Build the admin consent URL $adminConsentUrl = ` 'https://login.microsoftonline.com/' ` + $Context.TenantId ` + '/adminconsent?client_id=' ` + $AppRegistration.AppId ` + $redirectUri Write-Verbose 'Please go to the following URL in your browser to provide admin consent:' -Verbose Write-AuditLog "`n`n$adminConsentUrl`n" -Severity information -InformationAction Continue # For each end Write-Verbose 'After providing admin consent, you can use the following command for certificate-based auth:' -Verbose if ($AuthMethod -eq 'Certificate') { $connectGraph = 'Connect-MgGraph -ClientId "' + $AppRegistration.AppId + '" -TenantId "' + $Context.TenantId + '" -CertificateName "' + $cert.SubjectName.Name + '"' Write-AuditLog "`n`n$connectGraph`n" -Severity Information -InformationAction Continue } else { # Placeholder for other auth methods Write-AuditLog "Future logic for $AuthMethod auth can go here." throw "AuthMethod $AuthMethod is not yet implemented." } return $adminConsentUrl } catch { Write-AuditLog -Message "Error occurred: $($_.Exception.Message)" -Severity "Error" throw } } end { Write-AuditLog -EndFunction } } #EndRegion '.\Private\New-TkAppSpOauth2Registration.ps1' 185 #Region '.\Private\New-TkExchangeEmailAppPolicy.ps1' -1 <# .SYNOPSIS Creates a new Exchange email application policy and optionally adds a user to a mail-enabled sending group. .DESCRIPTION The New-TkExchangeEmailAppPolicy function creates a new Exchange application access policy for a specified application registration and mail-enabled sending group. Optionally, it can add an authorized sender to the mail-enabled sending group. .PARAMETER AppRegistration The application registration object. This parameter is mandatory. .PARAMETER MailEnabledSendingGroup The mail-enabled sending group. This parameter is mandatory. .PARAMETER AuthorizedSenderUserName The username of the authorized sender to be added to the mail-enabled sending group. This parameter is optional. .EXAMPLE $AppRegistration = Get-MgApplication -ApplicationId "your-app-id" New-TkExchangeEmailAppPolicy -AppRegistration $AppRegistration -MailEnabledSendingGroup "YourGroup" -AuthorizedSenderUserName "UserName" This example creates a new Exchange application access policy for the specified application registration and mail-enabled sending group, and adds the specified user to the mail-enabled sending group. .EXAMPLE $AppRegistration = Get-MgApplication -ApplicationId "your-app-id" New-TkExchangeEmailAppPolicy -AppRegistration $AppRegistration -MailEnabledSendingGroup "YourGroup" This example creates a new Exchange application access policy for the specified application registration and mail-enabled sending group without adding any user to the group. .NOTES This function uses the Microsoft Graph PowerShell module and requires appropriate permissions to manage Exchange policies and distribution groups. #> function New-TkExchangeEmailAppPolicy { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param ( [Parameter( Mandatory = $true, HelpMessage = 'The application registration object. This parameter is mandatory.' )] [Microsoft.Graph.PowerShell.Models.IMicrosoftGraphApplication] $AppRegistration, [Parameter( Mandatory = $true, HelpMessage = 'The mail-enabled sending group. This parameter is mandatory.' )] [string] $MailEnabledSendingGroup, [Parameter( Mandatory = $false, HelpMessage = 'The username of the authorized sender to be added to the mail-enabled sending group. This parameter is optional.' )] [string] $AuthorizedSenderUserName ) # Begin Logging if (!($script:LogString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { $shouldProcessTarget = "'$MailEnabledSendingGroup, $AuthorizedSenderUserName' on AppId '$($AppRegistration.AppId)'" $shouldProcessOperation = 'Add-DistributionGroupMember, New-ApplicationAccessPolicy' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { if ($PSBoundParameters.ContainsKey('AuthorizedSenderUserName')) { Write-AuditLog "Adding User: $AuthorizedSenderUserName to the Mail Enabled Sending Group: $MailEnabledSendingGroup" Add-DistributionGroupMember ` -Identity $MailEnabledSendingGroup ` -Member $AuthorizedSenderUserName ` -Confirm:$false ` -ErrorAction Stop } Write-AuditLog -Message "Creating Exchange Application policy for $($MailEnabledSendingGroup) for AppId $($AppRegistration.AppId)." New-ApplicationAccessPolicy -AppId $AppRegistration.AppId ` -PolicyScopeGroupId $MailEnabledSendingGroup -AccessRight RestrictAccess ` -Description 'Limit MSG application to only send emails as a group of users' ` -Confirm:$false ` -ErrorAction Stop | Out-Null Write-AuditLog -Message "Created Exchange Application policy for $($MailEnabledSendingGroup)." } } catch { Write-AuditLog -Message "Error creating Exchange Application policy: $_" -Severity "Error" throw } Write-AuditLog -EndFunction } #EndRegion '.\Private\New-TkExchangeEmailAppPolicy.ps1' 80 #Region '.\Private\Set-TkJsonSecret.ps1' -1 <# .SYNOPSIS Stores a JSON representation of an object as a secret in a specified vault. .DESCRIPTION The Set-TkJsonSecret function converts a given object to JSON format and stores it as a secret in a specified vault. If the vault is not registered, it will be auto-registered using the specified vault module. The function supports overwriting existing secrets if the -Overwrite switch is specified. .PARAMETER Name The name under which to store the secret. This parameter is mandatory. .PARAMETER InputObject The object to convert to JSON and store. This parameter is mandatory. .PARAMETER VaultName The name of the vault where the secret will be stored. Defaults to 'GraphEmailAppLocalStore'. .PARAMETER VaultModuleName The name of the vault module to use if auto-registering the vault. Defaults to 'SecretManagement.JustinGrote.CredMan'. .PARAMETER Overwrite Switch to overwrite an existing secret of the same name without prompting. .EXAMPLE Set-TkJsonSecret -Name 'MySecret' -InputObject $myObject This example converts the object stored in $myObject to JSON and stores it as a secret named 'MySecret' in the default vault. .EXAMPLE Set-TkJsonSecret -Name 'MySecret' -InputObject $myObject -VaultName 'MyCustomVault' -VaultModuleName 'MyVaultModule' -Overwrite This example converts the object stored in $myObject to JSON and stores it as a secret named 'MySecret' in the 'MyCustomVault' vault, using 'MyVaultModule' for auto-registration if needed, and overwrites any existing secret with the same name. .NOTES If the specified vault is not registered, it will be auto-registered using the specified vault module. If the secret already exists and the -Overwrite switch is not specified, an error will be thrown. #> function Set-TkJsonSecret { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [OutputType([string])] param( [Parameter( Mandatory = $true, HelpMessage = 'The name under which to store the secret. Must be a non-empty string.' )] [ValidateNotNullOrEmpty()] [string] $Name, [Parameter( Mandatory = $true, HelpMessage = 'The object to convert to JSON and store. Must be a valid PSObject.' )] [ValidateNotNullOrEmpty()] [PSObject] $InputObject, [Parameter( Mandatory = $false, HelpMessage = 'Name of the vault. Defaults to GraphEmailAppLocalStore.' )] [string] $VaultName = 'GraphEmailAppLocalStore', [Parameter( Mandatory = $false, HelpMessage = 'Name of the vault module to use if auto-registering. Defaults to SecretManagement.JustinGrote.CredMan.' )] [string] $VaultModuleName = 'SecretManagement.JustinGrote.CredMan', [Parameter( Mandatory = $false, HelpMessage = 'Overwrite existing secret of the same name without prompting.' )] [switch] $Overwrite ) if (!($script:logString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { Write-AuditLog '###############################################' # Auto-register vault if missing if (!(Get-SecretVault -Name $VaultName -ErrorAction SilentlyContinue)) { Write-AuditLog -Message "Registering $VaultName using $VaultModuleName" Register-SecretVault -Name $VaultName -ModuleName $VaultModuleName -ErrorAction Stop Write-AuditLog -Message "Vault '$VaultName' registered." } else { Write-AuditLog "Vault '$VaultName' is already registered." } # Check if secret already exists $secretExists = (Get-SecretInfo -Name $Name -Vault $VaultName -ErrorAction SilentlyContinue) if ($secretExists) { if ($Overwrite) { $shouldProcessOperation = 'Remove-Secret' $shouldProcessTarget = "Name: '$Name' in vault '$VaultName'." if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { Write-AuditLog -Message "Overwriting existing secret '$Name' in vault '$VaultName'." Remove-Secret -Name $Name -Vault $VaultName -Confirm:$false -ErrorAction Stop } else { Write-AuditLog -Message "Overwrite of existing secret '$Name' in vault '$VaultName' was cancelled." -Severity Warning throw } } else { Write-AuditLog -Message "Secret '$Name' already exists. Remove it or specify -Overwrite to overwrite." -Verbose throw } } $json = ($InputObject | ConvertTo-Json -Compress) Set-Secret -Name $Name -Secret $json -Vault $VaultName -ErrorAction Stop Write-AuditLog -Message "Secret '$Name' saved to vault '$VaultName'." Write-AuditLog -EndFunction return $Name } catch { throw } } #EndRegion '.\Private\Set-TkJsonSecret.ps1' 107 #Region '.\Private\Test-IsAdmin.ps1' -1 function Test-IsAdmin { <# .SYNOPSIS Checks if the current user is an administrator on the machine. .DESCRIPTION This private function returns a Boolean value indicating whether the current user has administrator privileges on the machine. It does this by creating a new WindowsPrincipal object, passing in a WindowsIdentity object representing the current user, and then checking if that principal is in the Administrator role. .INPUTS None. .OUTPUTS Boolean. Returns True if the current user is an administrator, and False otherwise. .EXAMPLE PS C:\> Test-IsAdmin True #> # Create a new WindowsPrincipal object for the current user and check if it is in the Administrator role (New-Object Security.Principal.WindowsPrincipal ([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) } #EndRegion '.\Private\Test-IsAdmin.ps1' 22 #Region '.\Private\Write-AuditLog.ps1' -1 <# .SYNOPSIS Writes log messages to the console and updates the script-wide log variable. .DESCRIPTION The Write-AuditLog function writes log messages to the console based on the severity (Verbose, Warning, or Error) and updates the script-wide log variable ($script:LogString) with the log entry. You can use the Start, End, and EndFunction switches to manage the lifecycle of the logging. .INPUTS System.String You can pipe a string to the Write-AuditLog function as the Message parameter. You can also pipe an object with a Severity property as the Severity parameter. .OUTPUTS None The Write-AuditLog function doesn't output any objects to the pipeline. It writes messages to the console and updates the script-wide log variable ($script:LogString). .PARAMETER BeginFunction Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable. .PARAMETER Message The message string to log. .PARAMETER Severity The severity of the log message. Accepted values are 'Information', 'Warning', 'Error'. Defaults to 'Verbose'. .PARAMETER Start Initializes the script-wide log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function. .PARAMETER End Sets the message to "End Log" and exports the log to a CSV file if the OutputPath parameter is provided. .PARAMETER EndFunction Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable. .PARAMETER OutputPath The file path for exporting the log to a CSV file when using the End switch. .EXAMPLE Write-AuditLog -Message "This is a test message." Writes a test message with the default severity (Verbose) to the console and adds it to the log variable. .EXAMPLE Write-AuditLog -Message "This is a warning message." -Severity "Warning" Writes a warning message to the console and adds it to the log variable. .EXAMPLE Write-AuditLog -Start Initializes the log variable and sets the message to "Begin [FunctionName] Log.", where FunctionName is the name of the calling function. .EXAMPLE Write-AuditLog -BeginFunction Sets the message to "Begin [FunctionName] function log.", where FunctionName is the name of the calling function, and adds it to the log variable. .EXAMPLE Write-AuditLog -EndFunction Sets the message to "End [FunctionName] log.", where FunctionName is the name of the calling function, and adds it to the log variable. .EXAMPLE Write-AuditLog -End -OutputPath "C:\Logs\auditLog.csv" Sets the message to "End Log", adds it to the log variable, and exports the log to a CSV file. .NOTES Author: DrIOSx #> function Write-AuditLog { [CmdletBinding( DefaultParameterSetName = 'Default' )] param( ### [Parameter( Mandatory = $false, HelpMessage = 'Input a Message string.', Position = 0, ParameterSetName = 'Default', ValueFromPipeline = $true )] [ValidateNotNullOrEmpty()] [string]$Message, ### [Parameter( Mandatory = $false, HelpMessage = 'Information, Warning or Error.', Position = 1, ParameterSetName = 'Default', ValueFromPipelineByPropertyName = $true )] [ValidateNotNullOrEmpty()] [ValidateSet('Information', 'Warning', 'Error', 'Verbose')] [string]$Severity = 'Verbose', ### [Parameter( Mandatory = $false, ParameterSetName = 'End' )] [switch]$End, ### [Parameter( Mandatory = $false, ParameterSetName = 'BeginFunction' )] [switch]$BeginFunction, ### [Parameter( Mandatory = $false, ParameterSetName = 'EndFunction' )] [switch]$EndFunction, ### [Parameter( Mandatory = $false, ParameterSetName = 'Start' )] [switch]$Start, ### [Parameter( Mandatory = $false, ParameterSetName = 'End' )] [string]$OutputPath ) begin { $ErrorActionPreference = 'SilentlyContinue' # Define variables to hold information about the command that was invoked. $moduleName = $Script:MyInvocation.MyCommand.Name -replace '\..*' $callStack = Get-PSCallStack if ($callStack.Count -gt 1) { $funcName = $callStack[1].Command } else { $funcName = 'DirectCall' # Or any other default name you prefer } $moduleVer = $MyInvocation.MyCommand.Version.ToString() # Set the error action preference to continue. $ErrorActionPreference = 'Continue' } process { try { if (-not $Start -and -not (Test-Path variable:script:LogString)) { throw "The logging variable is not initialized. Please call Write-AuditLog with the -Start switch or ensure $script:LogString is set." } $function = $($funcName + '.v' + $moduleVer) if ($Start) { $script:LogString = @() $Message = '+++ Begin Log +++ | ' + $function + ' |' } elseif ($BeginFunction) { $Message = '>>> Begin Function Log >>> | ' + $function + ' |' } $logEntry = [pscustomobject]@{ Time = ((Get-Date).ToString('yyyy-MM-dd hh:mmTss')) Module = $moduleName PSVersion = ($PSVersionTable.PSVersion).ToString() PSEdition = ($PSVersionTable.PSEdition).ToString() IsAdmin = $(Test-IsAdmin) User = "$Env:USERDOMAIN\$Env:USERNAME" HostName = $Env:COMPUTERNAME InvokedBy = $function Severity = $Severity Message = $Message RunID = -1 } if ($BeginFunction) { $maxRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $function } | Measure-Object -Property RunID -Maximum).Maximum if ($null -eq $maxRunID) { $maxRunID = -1 } $logEntry.RunID = $maxRunID + 1 } else { $lastRunID = ($script:LogString | Where-Object { $_.InvokedBy -eq $function } | Select-Object -Last 1).RunID if ($null -eq $lastRunID) { $lastRunID = 0 } $logEntry.RunID = $lastRunID } if ($EndFunction) { $functionStart = "$((($script:LogString | Where-Object {$_.InvokedBy -eq $function -and $_.RunId -eq $lastRunID } | Sort-Object Time)[0]).Time)" $startTime = ([DateTime]::ParseExact("$functionStart", 'yyyy-MM-dd hh:mmTss', $null)) $endTime = Get-Date $timeTaken = $endTime - $startTime $Message = '<<< End Function Log <<< | ' + $function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec" $logEntry.Message = $Message } elseif ($End) { $startTime = ([DateTime]::ParseExact($($script:LogString[0].Time), 'yyyy-MM-dd hh:mmTss', $null)) $endTime = Get-Date $timeTaken = $endTime - $startTime $Message = '--- End Log | ' + $function + ' | Runtime: ' + "$($timeTaken.Minutes) min $($timeTaken.Seconds) sec" $logEntry.Message = $Message } $script:LogString += $logEntry switch ($Severity) { 'Warning' { Write-Warning ('[WARNING] ! ' + $Message) } 'Error' { Write-Error ('[ERROR] X - ' + "[$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $funcName + ' ' + $Message) -ErrorAction Continue } 'Verbose' { Write-Verbose ('~ ' + "[$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $Message) } Default { Write-Information ("[NFO] [$((Get-Date).ToString('yyyy.MM.dd HH:mm:ss.fff'))] " + $Message) } } } catch { throw } } end { try { if ($End) { if (-not [string]::IsNullOrEmpty($OutputPath)) { $script:LogString | Export-Csv -Path $OutputPath -NoTypeInformation Write-Verbose "LogPath: $(Split-Path -Path $OutputPath -Parent)" } else { throw 'OutputPath is not specified for End action.' } } } catch { throw "Error in Write-AuditLog (end block): $($_.Exception.Message)" } } } #EndRegion '.\Private\Write-AuditLog.ps1' 211 #Region '.\Public\New-MailEnabledSendingGroup.ps1' -1 <# .SYNOPSIS Creates or retrieves a mail-enabled security group with a custom or default domain. .DESCRIPTION The New-MailEnabledSendingGroup function ensures that a mail-enabled security group is available for restricting email sending in Exchange Online. If a group of the specified name already exists and is security-enabled, the function returns that group. Otherwise, it creates a new security-enabled distribution group. You can specify either a custom primary SMTP address (via the 'CustomDomain' parameter set) or construct one using an alias and default domain (via the 'DefaultDomain' parameter set). By default, the 'CustomDomain' parameter set is used. If you wish to construct the SMTP address from the alias, switch to the 'DefaultDomain' parameter set. .PARAMETER Name The name of the mail-enabled security group to create or retrieve. This is also used as the alias if no separate Alias parameter is provided. .PARAMETER Alias An optional alias for the group. If omitted, the group name is used as the alias. .PARAMETER PrimarySmtpAddress (CustomDomain parameter set) The full SMTP address for the group (e.g. "MyGroup@contoso.com"). This parameter is mandatory when using the 'CustomDomain' parameter set. .PARAMETER DefaultDomain (DefaultDomain parameter set) The domain portion to be appended to the group alias (e.g. "Alias@DefaultDomain"). This parameter is mandatory when using the 'DefaultDomain' parameter set. .PARAMETER LogOutputPath An optional path to output the log file. If not provided, logs will not be written to a file. .EXAMPLE PS C:\> New-MailEnabledSendingGroup -Name "SecureSenders" -DefaultDomain "contoso.com" Creates a new mail-enabled security group named "SecureSenders" with a primary SMTP address of SecureSenders@contoso.com. .EXAMPLE PS C:\> New-MailEnabledSendingGroup -Name "SecureSenders" -Alias "Senders" -PrimarySmtpAddress "Senders@customdomain.org" Creates a new mail-enabled security group named "SecureSenders" with an alias "Senders" and a primary SMTP address of Senders@customdomain.org. .INPUTS None. This function does not accept pipeline input. .OUTPUTS Microsoft.Exchange.Data.Directory.Management.DistributionGroup Returns the newly created or existing mail-enabled security group object. .NOTES - Requires connectivity to Exchange Online (Connect-TkMsService -ExchangeOnline). - The caller must have sufficient privileges to create or modify distribution groups. - DefaultParameterSetName = 'CustomDomain'. #> function New-MailEnabledSendingGroup { [CmdletBinding(SupportsShouldProcess = $true , DefaultParameterSetName = 'CustomDomain')] param ( [Parameter( Mandatory = $true, HelpMessage = 'Specifies the name of the mail enabled sending group.' )] [string] $Name, [Parameter( Mandatory = $false, HelpMessage = 'Optional alias for the group. If not provided, the group name will be used.' )] [string] $Alias, [Parameter( Mandatory = $true, ParameterSetName = 'CustomDomain', HelpMessage = 'Specifies the primary SMTP address for the group when using a custom domain.' )] [string] $PrimarySmtpAddress, [Parameter( Mandatory = $true, ParameterSetName = 'DefaultDomain', HelpMessage = 'Specifies the default domain to construct the primary SMTP address (alias@DefaultDomain) for the group.' )] [string] $DefaultDomain, [Parameter( Mandatory = $false, HelpMessage = 'Optional path to output the log file. If not provided, logs will not be written to a file.' )] [string] $LogOutputPath ) if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { if ($PSCmdlet.ShouldProcess("Creating or retrieving mail-enabled security group '$Name'")) { Connect-TkMsService -ExchangeOnline if (-not $Alias) { $Alias = $Name } if ($PSCmdlet.ParameterSetName -eq 'DefaultDomain') { $PrimarySmtpAddress = "$Alias@$DefaultDomain" } # Check if the distribution group already exists $existingGroup = Get-DistributionGroup -Identity $Name -ErrorAction SilentlyContinue if ($existingGroup) { # Confirm the group is security-enabled if ($existingGroup.GroupType -notmatch 'SecurityEnabled') { throw "Group '$Name' exists but is not SecurityEnabled. Please provide a mail-enabled security group." } Write-AuditLog -Message "Distribution group '$Name' already exists. Returning existing group." return $existingGroup } # Create the distribution group $groupParams = @{ Name = $Name Alias = $Alias PrimarySmtpAddress = $PrimarySmtpAddress Type = 'security' } Write-AuditLog -Message "Creating distribution group with parameters: `n$($groupParams | Out-String)" $shouldProcessOperation = 'New-DistributionGroup' $shouldProcessTarget = "'$PrimarySmtpAddress'" if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { $group = New-DistributionGroup @groupParams Write-AuditLog -Message "Distribution group created:`n$($group | Out-String)" return $group } } } catch { throw } finally { Write-AuditLog -EndFunction if ($LogOutputPath) { Write-AuditLog -End -OutputPath $LogOutputPath } } } #EndRegion '.\Public\New-MailEnabledSendingGroup.ps1' 132 #Region '.\Public\Publish-TkEmailApp.ps1' -1 <# .SYNOPSIS Publishes a new or existing Graph Email App with specified configurations. .DESCRIPTION The Publish-TkEmailApp function creates or configures a Graph Email App in Azure AD. It supports two scenarios: 1. Creating a new app with specified parameters. 2. Using an existing app and attaching a certificate to it. .PARAMETER AppPrefix The prefix used to initialize the Graph Email App. Must be 2-4 characters, letters, and numbers only. Default is 'Gtk'. .PARAMETER AuthorizedSenderUserName The username of the authorized sender. Must be a valid email address. .PARAMETER MailEnabledSendingGroup The mail-enabled security group. Must be a valid email address. .PARAMETER ExistingAppObjectId The AppId of the existing App Registration to which you want to attach a certificate. Must be a valid GUID. .PARAMETER CertPrefix Prefix to add to the certificate subject for the existing app. .PARAMETER CertThumbprint The thumbprint of the certificate to be retrieved. Must be a valid 40-character hexadecimal string. .PARAMETER KeyExportPolicy Key export policy for the certificate. Valid values are 'Exportable' and 'NonExportable'. Default is 'NonExportable'. .PARAMETER VaultName If specified, use a custom vault name. Otherwise, use the default 'GraphEmailAppLocalStore'. .PARAMETER OverwriteVaultSecret If specified, overwrite the vault secret if it already exists. .PARAMETER ReturnParamSplat If specified, return the parameter splat for use in other functions. .PARAMETER DoNotUseDomainSuffix Switch to add session domain suffix to the app name. .PARAMETER LogOutput If specified, log the output to the console. .EXAMPLE # Permissions required for app registration: - 'Application.ReadWrite.All' - 'DelegatedPermissionGrant.ReadWrite.All' - 'Directory.ReadWrite.All' - 'RoleManagement.ReadWrite.Directory' # Permissions granted to the app: - 'Mail.Send' (Application) - Send mail as any user # Exchange application policy restricts send to a mail enabled security group # Ensure a mail enabled sending group is created first: $DefaultDomain = 'contoso.com' $MailEnabledSendingGroupToCreate = "CTSO-GraphAPIMail" # Creates a mail-enabled security group named "MySenders" using a default domain $group = New-MailEnabledSendingGroup -Name $MailEnabledSendingGroupToCreate -DefaultDomain $DefaultDomain # Create a new Graph Email App for a single tenant $LicensedUserToSendAs = 'helpdesk@contoso.com' Publish-TkEmailApp ` -AuthorizedSenderUserName $LicensedUserToSendAs ` -MailEnabledSendingGroup $group.PrimarySmtpAddress ` -ReturnParamSplat # Returns an app named like 'GraphToolKit-Gtk-<Session AD Domain>-As-helpdesk' # Returns a param splat that can be used as input for the send mail function: # Example: $params = @{ AppId = 'your-app-id' Id = 'your-app-object-id' AppName = 'GraphToolKit-Gtk-<Session AD Domain>-As-helpdesk' CertificateSubject = 'GraphToolKit-GTK-<Session AD Domain>-As-helpdesk' AppRestrictedSendGroup = 'CTSO-GraphAPIMail@contoso.com' CertExpires = 'yyyy-MM-dd HH:mm:ss' CertThumbprint = 'your-cert-thumbprint' ConsentUrl = 'https://login.microsoftonline.com/<your-tenant-id>/adminconsent?client_id=<your-app-id>' DefaultDomain = 'contoso.com' SendAsUser = 'helpdesk' SendAsUserEmail = 'helpdesk@contoso.com' TenantID = 'your-tenant-id' } .EXAMPLE # Create a multi client app registration where one app exists and multiple certificates are associated to the app: # Initial setup: # Create the group as before (or reuse the existing group) and run the following commands: $LicensedUserToSendAs = 'helpdesk@contoso.com' $CertPrefix = "CTSO" # First Company prefix. This will be used to prefix the certificate subject. Publish-TkEmailApp ` -CertPrefix $CertPrefix ` -AuthorizedSenderUserName $LicensedUserToSendAs ` -MailEnabledSendingGroup $group.PrimarySmtpAddress ` -ReturnParamSplat # Returns an app named like 'GraphToolKit-Gtk-<Session AD Domain>-As-helpdesk' $params = @{ AppId = 'your-app-id' Id = 'your-app-object-id' AppName = 'GraphToolKit-Gtk-<Session AD Domain>-As-helpdesk' CertificateSubject = 'GraphToolKit-CTSO-<Session AD Domain>-As-helpdesk' AppRestrictedSendGroup = 'CTSO-GraphAPIMail@contoso.com' CertExpires = 'yyyy-MM-dd HH:mm:ss' CertThumbprint = 'your-cert-thumbprint' ConsentUrl = 'https://login.microsoftonline.com/<your-tenant-id>/adminconsent?client_id=<your-app-id>' DefaultDomain = 'contoso.com' SendAsUser = 'helpdesk' SendAsUserEmail = 'helpdesk@contoso.com' TenantID = 'your-tenant-id' } $useExistingParams = @{ ExistingAppObjectId = $params.Id CertPrefix = 'NewCompany' OverwriteVaultSecret = $true # optional, if you want to overwrite the existing vault secret ReturnParamSplat = $true # optional, returns the param splat } Publish-TkEmailApp @useExistingParams # The new Cert will be prefixed with the new company prefix and will allow the current client to authenticate. # Back in the app registrations console, if you look at the internal notes in the properties of the app: # The app's "Internal Notes" will be populated with the following json: # Assists in tracking the app's usage and configuration. { "GraphEmailAppFor": "helpdesk@contoso.com", "RestrictedToGroup": "CTSO-GraphAPIMail@contoso.com", "AppPermissions": "Mail.Send", "New-Company_ClientIP": "<Public IP Address of the client where the app was called>", "New-Company_Host": "<Host of the client where the app was called>", "NewCoolCompany_ClientIP": "<Public IP Address of the client where the app was called>", "NewCoolCompany_Host": "Host of the client where the app was called>" } # New cert additions added through the toolkit will append new client info to these notes. .NOTES This cmdlet requires that the user running the cmdlet have the necessary permissions to create the app and connect to Exchange Online. #> function Publish-TkEmailApp { [CmdletBinding(SupportsShouldProcess = $true , ConfirmImpact = 'High', DefaultParameterSetName = 'CreateNewApp')] param( # REGION: CREATE NEW APP param set [Parameter( Mandatory = $false, ParameterSetName = 'CreateNewApp', HelpMessage = ` 'The prefix used to initialize the Graph Email App. 2-4 characters letters and numbers only.' )] [ValidatePattern('^[A-Z0-9]{2,4}$')] [string] $AppPrefix = 'Gtk', [Parameter( Mandatory = $true, ParameterSetName = 'CreateNewApp', HelpMessage = ` 'The username of the authorized sender.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $AuthorizedSenderUserName, [Parameter( Mandatory = $true, ParameterSetName = 'CreateNewApp', HelpMessage = ` 'The Mail Enabled Sending Group.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $MailEnabledSendingGroup, # REGION: USE EXISTING APP param set [Parameter( Mandatory = $true, ParameterSetName = 'UseExistingApp', HelpMessage = ` 'The AppId of the existing App Registration to which you want to attach a certificate.' )] [ValidatePattern('^[0-9a-fA-F-]{36}$')] [string] $ExistingAppObjectId, [Parameter( Mandatory = $true, ParameterSetName = 'UseExistingApp', HelpMessage = ` 'Prefix to add to certificate subject for existing app.' )] [Parameter( Mandatory = $false, ParameterSetName = 'CreateNewApp', HelpMessage = ` 'Prefix to add to certificate subject for existing app.' )] [string] $CertPrefix, # REGION: Shared parameters [Parameter( Mandatory = $false, HelpMessage = ` 'The thumbprint of the certificate to be retrieved.' )] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = ` 'Key export policy for the certificate.' )] [ValidateSet('Exportable', 'NonExportable')] [string] $KeyExportPolicy = 'NonExportable', [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, use a custom vault name. Otherwise, use the default.' )] [string] $VaultName = 'GraphEmailAppLocalStore', [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, overwrite the vault secret if it already exists.' )] [switch] $OverwriteVaultSecret, [Parameter( Mandatory = $false, HelpMessage = ` 'Return the parameter splat for use in other functions.' )] [switch] $ReturnParamSplat, [Parameter( Mandatory = $false, HelpMessage = ` 'Switch to add session domain suffix to the app name.' )] [switch] $DoNotUseDomainSuffix, [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, log the output to the console to the specified log file.' )] [string] $LogOutput ) begin { <# This cmdlet requires that the user running the cmdlet have the necessary permissions to create the app and connect to Exchange Online. In addition, a mail-enabled security group must already exist in Exchange Online for the MailEnabledSendingGroup parameter. Permissions required: 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' #> if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { Write-AuditLog '###############################################' # 1) Ensure required modules are installed $PublicMods = 'Microsoft.Graph', 'ExchangeOnlineManagement', 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan' $PublicVers = '1.22.0', '3.1.0', '1.1.2', '1.0.0' $ImportMods = 'Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications', 'Microsoft.Graph.Identity.SignIns', 'Microsoft.Graph.Users' $ModParams = @{ PublicModuleNames = $PublicMods PublicRequiredVersions = $PublicVers ImportModuleNames = $ImportMods Scope = 'CurrentUser' } Initialize-TkModuleEnv @ModParams $scopesNeeded = @( 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All' ) } catch { throw } } process { $target = if ($AppPrefix) { $AppPrefix } else { $CertPrefix } $shouldProcessTarget = "Graph Email App $target" $shouldProcessOperation = 'Publish-TkEmailApp' if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { switch ($PSCmdlet.ParameterSetName) { # ------------------------------------------------------ # ============== SCENARIO 1: CREATE NEW APP ============= # ------------------------------------------------------ 'CreateNewApp' { # 2) Connect to both Graph and Exchange Connect-TkMsService ` -MgGraph ` -ExchangeOnline ` -GraphAuthScopes $scopesNeeded # 3) Grab MgContext for tenant info $Context = Get-MgContext if (!$Context) { throw 'Could not retrieve the context for the tenant.' } # 1) Validate the user (AuthorizedSenderUserName) is in tenant $user = Get-MgUser -Filter "Mail eq '$AuthorizedSenderUserName'" if (-not $user) { throw "User '$AuthorizedSenderUserName' not found in the tenant." } # 2) Build the app context (Mail.Send permission, etc.) $AppSettings = Initialize-TkRequiredResourcePermissionObject ` -GraphPermissions 'Mail.Send' $appName = Initialize-TkAppName ` -Prefix $AppPrefix ` -UserId $AuthorizedSenderUserName ` -DoNotUseDomainSuffix:$DoNotUseDomainSuffix ` -ErrorAction Stop # Verify if the secret already exists in the vault $existingSecret = Get-TkExistingSecret ` -AppName $appName ` -VaultName $VaultName ` -ErrorAction SilentlyContinue if ($ExistingSecret -and -not $OverwriteVaultSecret) { throw "Secret '$AppName' already exists in vault '$VaultName'. Use the -OverwriteVaultSecret switch to overwrite it." } # Add relevant properties $AppSettings | Add-Member -NotePropertyName 'User' -NotePropertyValue $user $AppSettings | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $appName if ($CertPrefix) { $updatedString = $appName -replace '(GraphToolKit-)[A-Za-z0-9]{2,4}(?=-)', "`$1$CertPrefix" $CertificateSubject = "CN=$updatedString" $ClientCertPrefix = "$certPrefix" } else { $CertificateSubject = "CN=$appName" $ClientCertPrefix = "$AppPrefix" } # 3) Create or retrieve the certificate $AppAuthCertificateParams = @{ AppName = $AppSettings.AppName Thumbprint = $CertThumbprint Subject = $CertificateSubject KeyExportPolicy = $KeyExportPolicy ErrorAction = 'Stop' } $CertDetails = Initialize-TkAppAuthCertificate @AppAuthCertificateParams # 4) Show the proposed object $proposedObject = [PSCustomObject]@{ ProposedAppName = $AppSettings.AppName ProposedCertificateSubject = $CertificateSubject CertificateThumbprintUsed = $CertDetails.CertThumbprint CertExpires = $CertDetails.CertExpires UserPrincipalName = $user.UserPrincipalName TenantID = $Context.TenantId Permissions = 'Mail.Send' PermissionType = 'Application' ConsentType = 'AllPrincipals' ExchangePolicyRestrictedToGroup = $MailEnabledSendingGroup } Write-AuditLog 'The following object will be created (or configured) in Azure AD:' Write-AuditLog ($proposedObject | Format-List | Out-String) # 5) Only proceed if ShouldProcess is allowed try { # Build a hashtable (or PSCustomObject) of the fields you want: $notesHash = [ordered]@{ GraphEmailAppFor = $AuthorizedSenderUserName RestrictedToGroup = $MailEnabledSendingGroup AppPermissions = 'Mail.Send' ($ClientCertPrefix + '_ClientIP') = (Invoke-RestMethod ifconfig.me/ip) ($ClientCertPrefix + '_Host') = $env:COMPUTERNAME } # Convert that hashtable to a JSON string: $Notes = $notesHash | ConvertTo-Json #-Compress # 6) Register the new enterprise app for Graph $AppRegistrationParams = @{ DisplayName = $AppSettings.AppName CertThumbprint = $CertDetails.CertThumbprint RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList SignInAudience = 'AzureADMyOrg' Notes = $Notes ErrorAction = 'Stop' } $appRegistration = New-TkAppRegistration @AppRegistrationParams # 7) Initialize the service principal, permissions, etc. $AppSpRegistrationParams = @{ AppRegistration = $appRegistration Context = $Context RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList Scopes = $permissionsObject AuthMethod = 'Certificate' CertThumbprint = $CertDetails.CertThumbprint ErrorAction = 'Stop' } $ConsentUrl = New-TkAppSpOauth2Registration @AppSpRegistrationParams [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.') # 8) Create the Exchange Online policy restricting send New-TkExchangeEmailAppPolicy ` -AppRegistration $appRegistration ` -MailEnabledSendingGroup $MailEnabledSendingGroup ` -AuthorizedSenderUserName $AuthorizedSenderUserName # 9) Build final output object $EmailAppParams = @{ AppId = $appRegistration.AppId Id = $appRegistration.Id AppName = "$($AppSettings.AppName)" CertificateSubject = $CertificateSubject AppRestrictedSendGroup = $MailEnabledSendingGroup CertExpires = $CertDetails.CertExpires CertThumbprint = $CertDetails.CertThumbprint ConsentUrl = $ConsentUrl DefaultDomain = $MailEnabledSendingGroup.Split('@')[1] SendAsUser = $AppSettings.User.UserPrincipalName.Split('@')[0] SendAsUserEmail = $AppSettings.User.UserPrincipalName TenantID = $Context.TenantId } [TkEmailAppParams]$graphEmailApp = Initialize-TkEmailAppParamsObject @EmailAppParams # 10) Store it as JSON in the vault $JsonSecretParams = @{ Name = "CN=$($AppSettings.AppName)" InputObject = $graphEmailApp VaultName = $VaultName Overwrite = $OverwriteVaultSecret ErrorAction = 'Stop' } $savedSecretName = Set-TkJsonSecret @JsonSecretParams Write-AuditLog "Secret '$savedSecretName' saved to vault '$VaultName'." } catch { throw } } # --------------------------------------------------------- # ============ SCENARIO 2: USE EXISTING APP =============== # --------------------------------------------------------- 'UseExistingApp' { # Grab MgContext for tenant info Connect-TkMsService ` -MgGraph ` -GraphAuthScopes $scopesNeeded $Context = Get-MgContext if (!$Context) { throw 'Could not retrieve the context for the tenant.' } $ClientCertPrefix = "$CertPrefix" # Retrieve the existing app registration by AppId Write-AuditLog "Looking up existing app with ObjectId: $ExistingAppObjectId" # Get-MgApplication uses the application object id, not the app id $existingApp = Get-MgApplication -ApplicationId $ExistingAppObjectId -ErrorAction Stop if (-not $existingApp) { throw "Could not find an existing application with AppId '$ExistingAppObjectId'." } if (!($existingApp | Where-Object { $_.DisplayName -like 'GraphToolKit-*' })) { throw "The existing app with AppId '$ExistingAppObjectId' is not a GraphToolKit app." } $updatedString = $existingApp.DisplayName -replace '(GraphToolKit-)[A-Za-z0-9]{2,4}(?=-)', "`$1$CertPrefix" # Retrieve or create the certificate $certParams = @{ AppName = $updatedString Thumbprint = $CertThumbprint Subject = "CN=$updatedString" KeyExportPolicy = $KeyExportPolicy ErrorAction = 'Stop' } $certDetails = Initialize-TkAppAuthCertificate @certParams Write-AuditLog "Attaching certificate (Thumbprint: $($certDetails.CertThumbprint)) to existing app '$($existingApp.DisplayName)'." # Merge or append the new certificate to the existing KeyCredentials $currentKeys = $existingApp.KeyCredentials $newCert = @{ Type = 'AsymmetricX509Cert' Usage = 'Verify' Key = (Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $certDetails.CertThumbprint }).RawData DisplayName = "CN=$updatedString" } # If you want to specify start/end date, you can do so as well: # $newCert.StartDateTime = (Get-Date) # $newCert.EndDateTime = (Get-Date).AddYears(1) # Append the new cert to existing $mergedKeys = $currentKeys + $newCert $existingNotesRaw = $existingApp.Notes if (-not [string]::IsNullOrEmpty($existingNotesRaw)) { try { $notesObject = $existingNotesRaw | ConvertFrom-Json -ErrorAction Stop } catch { Write-AuditLog 'Existing .Notes was not valid JSON; ignoring it.' $notesObject = [ordered]@{} } } else { $notesObject = [ordered]@{} } # Add your new properties each time the function runs $notesObject | Add-Member -NotePropertyName ($clientCertPrefix + '_ClientIP') -NotePropertyValue (Invoke-RestMethod ifconfig.me/ip) $notesObject | Add-Member -NotePropertyName ($clientCertPrefix + '_Host') -NotePropertyValue $env:COMPUTERNAME $updatedNotes = $notesObject | ConvertTo-Json #-Compress if (($updatedNotes.length -gt 1024)) { throw 'The Notes object is too large. Please reduce the size of the Notes object.' } try { # Update the application with the new KeyCredentials array $updateAppParams = @{ ApplicationId = $existingApp.Id KeyCredentials = $mergedKeys Notes = $updatedNotes ErrorAction = 'Stop' } Update-MgApplication @updateAppParams | Out-Null # Build an output object similar to "new" scenario $emailAppParams = @{ AppId = $existingApp.AppId Id = $existingApp.Id AppName = "$updatedString" CertificateSubject = "CN=$updatedString" AppRestrictedSendGroup = $notesObject.RestrictedToGroup CertExpires = $certDetails.CertExpires CertThumbprint = $certDetails.CertThumbprint ConsentUrl = $null DefaultDomain = ($notesObject.GraphEmailAppFor.Split('@')[1]) SendAsUser = ($notesObject.GraphEmailAppFor.Split('@')[0]) SendAsUserEmail = $notesObject.GraphEmailAppFor TenantID = $context.TenantId } [TkEmailAppParams]$graphEmailApp = Initialize-TkEmailAppParamsObject @emailAppParams # Store updated info in the vault $jsonSecretParams = @{ Name = "CN=$updatedString" InputObject = $graphEmailApp VaultName = $VaultName Overwrite = $OverwriteVaultSecret ErrorAction = 'Stop' } $savedSecretName = Set-TkJsonSecret @JsonSecretParams Write-AuditLog "Secret for existing app saved as '$savedSecretName' in vault '$VaultName'." } catch { throw } } } # end switch } } end { if ($ReturnParamSplat -and $graphEmailApp) { return ($graphEmailApp | ConvertTo-ParameterSplat) } elseif ($graphEmailApp) { return $graphEmailApp } if ($LogOutput) { Write-AuditLog -End -LogOutput $LogOutput } } } #EndRegion '.\Public\Publish-TkEmailApp.ps1' 539 #Region '.\Public\Publish-TkM365AuditApp.ps1' -1 <# .SYNOPSIS Publishes (creates) a new M365 Audit App registration in Entra ID (Azure AD) with a specified certificate. .DESCRIPTION The Publish-TkM365AuditApp function creates a new Azure AD application used for M365 auditing. It connects to Microsoft Graph, gathers the required permissions for SharePoint and Exchange, and optionally creates a self-signed certificate if no thumbprint is provided. It also assigns the application to the Exchange Administrator and Global Reader roles. By default, the newly created application details are stored as a secret in the specified SecretManagement vault. .PARAMETER AppPrefix A short prefix (2-4 alphanumeric characters) used to build the app name. Defaults to "Gtk" if not specified. Example app name: GraphToolKit-MSN-GraphApp-MyDomain-As-helpDesk .PARAMETER CertThumbprint The thumbprint of an existing certificate in the current user's certificate store. If not provided, a new self-signed certificate is created. .PARAMETER KeyExportPolicy Specifies whether the newly created certificate (if no thumbprint is provided) is 'Exportable' or 'NonExportable'. Defaults to 'NonExportable'. .PARAMETER VaultName The SecretManagement vault name in which to store the app credentials. Defaults to "M365AuditAppLocalStore" if not specified. .PARAMETER OverwriteVaultSecret If specified, overwrites an existing secret in the specified vault if it already exists. .PARAMETER ReturnParamSplat If specified, returns a parameter splat string for use in other functions, instead of the default PSCustomObject containing the app details. .PARAMETER DoNotUseDomainSuffix If specified, does not append the domain suffix to the app name. .EXAMPLE PS C:\> Publish-TkM365AuditApp -AppPrefix "CS12" -ReturnParamSplat Creates a new M365 Audit App with the prefix "CS12", returns a parameter splat, and stores the credentials in the default vault. .INPUTS None. This function does not accept pipeline input. .OUTPUTS By default, returns a PSCustomObject with details of the new app (AppId, ObjectId, TenantId, certificate thumbprint, expiration, etc.). If -ReturnParamSplat is used, returns a parameter splat string. .NOTES Requires the Microsoft.Graph and ExchangeOnlineManagement modules for app creation and role assignment. The user must have sufficient privileges to create and manage applications in Azure AD, and to assign roles. After creation, admin consent may be required for the assigned permissions. Permissions required for app registration: 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' Permissions granted to the app: (Exchange Administrator and Global Reader Roles are also added to the service principal.) 'AppCatalog.ReadWrite.All', 'Channel.Delete.All', 'ChannelMember.ReadWrite.All', 'ChannelSettings.ReadWrite.All', 'Directory.Read.All', 'Group.ReadWrite.All', 'Organization.Read.All', 'Policy.Read.All', 'Domain.Read.All', 'TeamSettings.ReadWrite.All', 'User.Read.All', 'Sites.Read.All', 'Sites.FullControl.All', 'Exchange.ManageAsApp' #> function Publish-TkM365AuditApp { [CmdletBinding(ConfirmImpact = 'High')] param( [Parameter( Mandatory = $false, HelpMessage = ` 'Prefix for the new M365 Audit app name (2-4 alphanumeric characters).' )] [ValidatePattern('^[A-Z0-9]{2,4}$')] [string] $AppPrefix = 'Gtk', [Parameter( Mandatory = $false, HelpMessage = ` 'Thumbprint of an existing certificate to use. If not provided, a self-signed cert will be created.' )] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = 'Key export policy for the certificate.' )] [ValidateSet('Exportable', 'NonExportable')] [string] $KeyExportPolicy = 'NonExportable', [Parameter( Mandatory = $false, HelpMessage = ` 'Name of the SecretManagement vault to store app credentials.' )] [string] $VaultName = 'M365AuditAppLocalStore', [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, overwrite the vault secret if it already exists.' )] [switch] $OverwriteVaultSecret, [Parameter( Mandatory = $false, HelpMessage = ` 'Return output as a parameter splat string for use in other functions.' )] [switch]$ReturnParamSplat, [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, do not append the domain suffix to the app name.' )] [switch]$DoNotUseDomainSuffix ) begin { if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' Write-AuditLog 'Initializing M365 Audit App publication process...' $scopesNeeded = @( 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' ) # 1) Connect to Graph so we can query permissions & create the app Connect-TkMsService -MgGraph -GraphAuthScopes $scopesNeeded } process { try { # 2) Define read-only vs. read-write sets $graph = @( 'AppCatalog.ReadWrite.All', 'Channel.Delete.All', 'ChannelMember.ReadWrite.All', 'ChannelSettings.ReadWrite.All', 'Directory.Read.All', 'Group.ReadWrite.All', 'Organization.Read.All', 'Policy.Read.All', 'Domain.Read.All' 'TeamSettings.ReadWrite.All' 'User.Read.All' ) $sharePoint = @('Sites.Read.All', 'Sites.FullControl.All') $exchange = @('Exchange.ManageAsApp') # Decide which sets to use $permissionsObject = [PSCustomObject]@{ Graph = $graph SharePoint = $sharePoint Exchange = $exchange } Write-AuditLog "Graph Perms: $($graph -join ', ')" Write-AuditLog "SharePoint Perms: $($sharePoint -join ', ')" Write-AuditLog "Exchange Perms: $($exchange -join ', ')" $Context = Get-MgContext -ErrorAction Stop # Gather the resource access objects (GUIDs) for all these perms $AppSettings = Initialize-TkRequiredResourcePermissionObject ` -GraphPermissions $graph ` -Scenario '365Audit' ` -ErrorAction Stop # Generate the app name $appName = Initialize-TkAppName ` -Prefix $AppPrefix ` -ScenarioName 'M365Audit' ` -DoNotUseDomainSuffix:$DoNotUseDomainSuffix ` -ErrorAction Stop Write-AuditLog "Proposed new M365 Audit App name: $appName" # Retrieve or create the certificate $AppAuthCertificateParams = @{ AppName = $appName Thumbprint = $CertThumbprint Subject = "CN=$appName" KeyExportPolicy = $KeyExportPolicy ErrorAction = 'Stop' } $CertDetails = Initialize-TkAppAuthCertificate @AppAuthCertificateParams Write-AuditLog "Certificate Thumbprint: $($CertDetails.CertThumbprint); Expires: $($CertDetails.CertExpires)." # Show user proposed config $proposed = [PSCustomObject]@{ ProposedAppName = $appName CertificateThumbprint = $CertDetails.CertThumbprint CertExpires = $CertDetails.CertExpires GraphPermissions = $graph -join ', ' SharePointPermissions = $sharePoint -join ', ' ExchangePermissions = $exchange -join ', ' } Write-AuditLog 'Proposed creation of a new M365 Audit App with the following properties:' Write-AuditLog "$($proposed | Format-List)" # Create the app in one pass with all resources $notesHash = [ordered]@{ 'Certificate Thumbprint' = $($CertDetails.CertThumbprint) 'Certificate Expires' = $($CertDetails.CertExpires) 'GraphAppPermissions' = $($graph -join ', ') 'SharePointAppPermissions' = $($sharePoint -join ', ') 'ExchangeAppPermissions' = $($exchange -join ', ') 'RolesAssigned' = @('Exchange Administrator', 'Global Reader') 'AuthorizedClient IP' = $((Invoke-RestMethod ifconfig.me/ip)) 'ClientOrUserHostname' = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { $env:USERNAME } } # Convert that hashtable to a JSON string: $Notes = $notesHash | ConvertTo-Json #-Compress Write-AuditLog 'Creating new EntraAD application with all resource permissions...' $AppRegistrationParams = @{ DisplayName = $appName CertThumbprint = $CertDetails.CertThumbprint RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList SignInAudience = 'AzureADMyOrg' Notes = $Notes ErrorAction = 'Stop' } $appRegistration = New-TkAppRegistration @AppRegistrationParams Write-AuditLog "App registered. Object ID = $($appRegistration.Id), ClientId = $($appRegistration.AppId)." # Grant the oauth2 permissions to service principal $AppSpRegistrationParams = @{ AppRegistration = $appRegistration Context = $Context RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList Scopes = $permissionsObject AuthMethod = 'Certificate' CertThumbprint = $CertDetails.CertThumbprint ErrorAction = 'Stop' } $ConsentUrl = New-TkAppSpOauth2Registration @AppSpRegistrationParams [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.') Write-AuditLog 'Appending Exchange Administrator role to the app.' $exoAdminRole = Get-MgDirectoryRole -Filter "displayName eq 'Exchange Administrator'" -ErrorAction Stop # Get the service principal object ID of the app $sp = Get-MgServicePrincipal -Filter "appId eq '$($appRegistration.appid)'" -ErrorAction Stop $spObjectId = $sp.Id $body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$spObjectId" } New-MgDirectoryRoleMemberByRef ` -DirectoryRoleId $exoAdminRole.Id ` -BodyParameter $body ` -ErrorAction Stop Write-AuditLog 'Appending Global Reader role to the app.' $globalReaderRole = Get-MgDirectoryRole ` -Filter "displayName eq 'Global Reader'" ` -ErrorAction Stop New-MgDirectoryRoleMemberByRef ` -DirectoryRoleId $globalReaderRole.Id ` -BodyParameter $body ` -ErrorAction Stop # Store final app info in the vault $M365AuditAppParams = @{ AppName = "CN=$appName" AppId = $appRegistration.AppId ObjectId = $appRegistration.Id TenantId = $context.TenantId CertThumbprint = $CertDetails.CertThumbprint CertExpires = $CertDetails.CertExpires ConsentUrl = $ConsentUrl MgGraphPermissions = "$graph" SharePointPermissions = "$sharePoint" ExchangePermissions = "$exchange" } [TkM365AuditAppParams]$m365AuditApp = Initialize-TkM365AuditAppParamsObject @M365AuditAppParams # Save to vault $JsonSecretParams = @{ Name = "CN=$appName" InputObject = $m365AuditApp VaultName = $VaultName Overwrite = $OverwriteVaultSecret ErrorAction = 'Stop' } $savedName = Set-TkJsonSecret @JsonSecretParams Write-AuditLog "Secret '$savedName' saved to vault '$VaultName'." # Return as either param splat or plain object if ($ReturnParamSplat) { return $m365AuditApp | ConvertTo-ParameterSplat } else { return $m365AuditApp } } catch { throw } finally { Write-AuditLog -EndFunction } } } #EndRegion '.\Public\Publish-TkM365AuditApp.ps1' 296 #Region '.\Public\Publish-TkMemPolicyManagerApp.ps1' -1 <# .SYNOPSIS Publishes a new MEM (Intune) Policy Manager App in Azure AD with read-only or read-write permissions. .DESCRIPTION The Publish-TkMemPolicyManagerApp function creates an Azure AD application intended for managing Microsoft Endpoint Manager (MEM/Intune) policies. It optionally creates or retrieves a certificate, configures the necessary Microsoft Graph permissions for read-only or read-write access, and stores the resulting app credentials in a SecretManagement vault. .PARAMETER AppPrefix A 2-4 character prefix used to build the application name (e.g., CORP, MSN). This helps uniquely identify the app in Azure AD. .PARAMETER CertThumbprint The thumbprint of an existing certificate in the current user's certificate store. If omitted, a new self-signed certificate is created. .PARAMETER KeyExportPolicy Specifies whether the newly created certificate is 'Exportable' or 'NonExportable'. Defaults to 'NonExportable' if not specified. .PARAMETER VaultName The name of the SecretManagement vault in which to store the app credentials. Defaults to 'MemPolicyManagerLocalStore'. .PARAMETER OverwriteVaultSecret If specified, overwrites any existing secret of the same name in the vault. .PARAMETER ReadWrite If specified, grants read-write MEM/Intune permissions. Otherwise, read-only permissions are granted. .PARAMETER ReturnParamSplat If specified, returns a parameter splat string for use in other functions. Otherwise, returns a PSCustomObject containing the app details. .PARAMETER DoNotUseDomainSuffix If specified, the function does not append the domain suffix to the app name. .EXAMPLE PS C:\> Publish-TkMemPolicyManagerApp -AppPrefix "CORP" -ReadWrite Creates a new MEM Policy Manager App with read-write permissions, retrieves or creates a certificate, and stores the credentials in the default vault. .INPUTS None. This function does not accept pipeline input. .OUTPUTS By default, returns a PSCustomObject (TkMemPolicyManagerAppParams) with details of the newly created app (AppId, certificate thumbprint, tenant ID, etc.). If -ReturnParamSplat is used, returns a parameter splat string. .NOTES This function requires the Microsoft.Graph module for application creation and the user must have permissions in Azure AD to register and grant permissions to the application. After creation, admin consent may be needed to finalize the permission grants. Permissions required for app registration:: 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All' Permissions required for read-only access: 'DeviceManagementConfiguration.Read.All', 'DeviceManagementApps.Read.All', 'DeviceManagementManagedDevices.Read.All', 'Policy.Read.ConditionalAccess', 'Policy.Read.All' Permissions required for read-write access: 'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementApps.ReadWrite.All', 'DeviceManagementManagedDevices.ReadWrite.All', 'Policy.ReadWrite.ConditionalAccess', 'Policy.Read.All' #> function Publish-TkMemPolicyManagerApp { [CmdletBinding(ConfirmImpact = 'High')] param( [Parameter( Mandatory = $true, HelpMessage = ` '2-4 character prefix used for the App Name (e.g. MSN, CORP, etc.)' )] [ValidatePattern('^[A-Z0-9]{2,4}$')] [string] $AppPrefix, [Parameter( Mandatory = $false, HelpMessage = ` 'Thumbprint of the certificate. If omitted, a self-signed cert is created.' )] [ValidatePattern('^[A-Fa-f0-9]{40}$')] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = ` 'Key export policy for the certificate.' )] [ValidateSet('Exportable', 'NonExportable')] [string] $KeyExportPolicy = 'NonExportable', [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, use a custom vault name. Otherwise, use the default.' )] [string] $VaultName = 'MemPolicyManagerLocalStore', [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, overwrite the vault secret if it already exists.' )] [switch] $OverwriteVaultSecret, [Parameter( HelpMessage = ` 'If specified, grant ReadWrite perms. Otherwise, read-only perms.' )] [switch] $ReadWrite, [Parameter( Mandatory = $false, HelpMessage = ` 'Return the param splat for use in other functions.' )] [switch]$ReturnParamSplat, [Parameter( Mandatory = $false, HelpMessage = ` 'If specified, do not append the domain suffix to the app name.' )] [switch] $DoNotUseDomainSuffix ) begin { if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { Write-AuditLog '###############################################' $PublicMods = 'Microsoft.Graph', 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan' $PublicVers = '1.22.0', '1.1.2', '1.0.0' $ImportMods = 'Microsoft.Graph.Authentication', 'Microsoft.Graph.Applications', 'Microsoft.Graph.Identity.SignIns', 'Microsoft.Graph.Users' $ModParams = @{ PublicModuleNames = $PublicMods PublicRequiredVersions = $PublicVers ImportModuleNames = $ImportMods Scope = 'CurrentUser' } Initialize-TkModuleEnv @ModParams # Only connect to Graph $scopesNeeded = @( 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All' ) Connect-TkMsService ` -MgGraph ` -GraphAuthScopes $scopesNeeded ` -ErrorAction Stop $Context = Get-MgContext -ErrorAction Stop } catch { throw } } process { try { # 1) Determine the correct set of MEM permissions $readWritePerms = @( 'DeviceManagementConfiguration.ReadWrite.All', 'DeviceManagementApps.ReadWrite.All', 'DeviceManagementManagedDevices.ReadWrite.All', 'Policy.ReadWrite.ConditionalAccess', 'Policy.Read.All' ) $readOnlyPerms = @( 'DeviceManagementConfiguration.Read.All', 'DeviceManagementApps.Read.All', 'DeviceManagementManagedDevices.Read.All', 'Policy.Read.ConditionalAccess', 'Policy.Read.All' ) $permissions = if ($ReadWrite) { $readWritePerms } else { $readOnlyPerms } $permissionsObject = [PSCustomObject]@{ Graph = $permissions } Write-AuditLog "Using the following MEM permissions: $($permissions -join ', ')" # 2) Build a Graph context object that looks up these permission IDs $AppSettings = Initialize-TkRequiredResourcePermissionObject ` -GraphPermissions $permissions # 3) Build an app name for scenario "MemPolicyManager" $appName = Initialize-TkAppName ` -Prefix $AppPrefix ` -ScenarioName 'MemPolicyManager' ` -DoNotUseDomainSuffix:$DoNotUseDomainSuffix ` -ErrorAction Stop # 4) Add TenantId & AppName to the object so we can store them in the final JSON $AppSettings | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue $Context.TenantId $AppSettings | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $appName # 5) Create or retrieve the certificate $AppAuthCertificateParams = @{ AppName = $AppSettings.AppName Thumbprint = $CertThumbprint Subject = "CN=$($AppSettings.AppName)" KeyExportPolicy = $KeyExportPolicy ErrorAction = 'Stop' } $CertDetails = Initialize-TkAppAuthCertificate @AppAuthCertificateParams # Build a “proposed” object so the user sees what’s about to happen $proposedObject = [PSCustomObject]@{ ProposedAppName = $AppSettings.AppName CertificateThumbprintUsed = $CertDetails.CertThumbprint CertificateExpires = $CertDetails.CertExpires TenantID = $Context.TenantId RequestedPermissions = ($permissions -join ', ') PermissionType = 'Application' } Write-AuditLog 'Proposed creation of a new MEM Policy Manager App with the following properties:' Write-AuditLog "$($proposedObject | Format-List)" $notesHash = [ordered]@{ 'Certificate Thumbprint' = $($CertDetails.CertThumbprint) 'Certificate Expires' = $($CertDetails.CertExpires) 'GraphAppPermissions' = $($permissions -join ', ') 'Read-Write Permissions' = $(if ($ReadWrite) { 'ReadWrite' } else { 'Read-Only' }) 'AuthorizedClient IP' = $((Invoke-RestMethod ifconfig.me/ip)) 'ClientOrUserHostname' = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { $env:USERNAME } } # Convert that hashtable to a JSON string: $Notes = $notesHash | ConvertTo-Json #-Compress # 6) Register the application (with the cert) $AppRegistrationParams = @{ DisplayName = $AppSettings.AppName CertThumbprint = $CertDetails.CertThumbprint RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList SignInAudience = 'AzureADMyOrg' Notes = $Notes ErrorAction = 'Stop' } $appRegistration = New-TkAppRegistration @AppRegistrationParams # 7) Create the Service Principal & grant the permissions $AppSpRegistrationParams = @{ AppRegistration = $appRegistration Context = $Context RequiredResourceAccessList = $AppSettings.RequiredResourceAccessList Scopes = $permissionsObject AuthMethod = 'Certificate' CertThumbprint = $CertDetails.CertThumbprint ErrorAction = 'Stop' } $ConsentUrl = New-TkAppSpOauth2Registration @AppSpRegistrationParams [void](Read-Host 'Provide admin consent now, or copy the url and provide admin consent later. Press Enter to continue.') # 8) Build a final PSCustomObject to store in the secret vault $TkMemPolicyManagerAppParams = @{ AppId = $appRegistration.AppId AppName = "CN=$($AppSettings.AppName)" CertThumbprint = $CertDetails.CertThumbprint ObjectId = $appRegistration.Id ConsentUrl = $ConsentUrl PermissionSet = if ($ReadWrite) { 'ReadWrite' } else { 'ReadOnly' } Permissions = $permissions TenantId = $Context.TenantId } [TkMemPolicyManagerAppParams]$AppParamsObject = Initialize-TkMemPolicyManagerAppParamsObject @TkMemPolicyManagerAppParams # 9) Store as JSON secret $JsonSecretParams = @{ Name = "CN=$($AppSettings.AppName)" InputObject = $AppParamsObject VaultName = $VaultName Overwrite = $OverwriteVaultSecret ErrorAction = 'Stop' } $savedName = Set-TkJsonSecret @JsonSecretParams Write-AuditLog "Secret '$savedName' saved to vault '$VaultName'." # Return the final object (param-splat or normal) if ($ReturnParamSplat) { return $AppParamsObject | ConvertTo-ParameterSplat } else { return $AppParamsObject } } catch { throw } } end { Write-AuditLog -EndFunction } } #EndRegion '.\Public\Publish-TkMemPolicyManagerApp.ps1' 284 #Region '.\Public\Send-TkEmailAppMessage.ps1' -1 <# .SYNOPSIS Sends an email using the Microsoft Graph API, either by retrieving app credentials from a local vault or by specifying them manually. .DESCRIPTION The Send-TkEmailAppMessage function uses the Microsoft Graph API to send an email to a specified recipient. It supports two parameter sets: 1. 'Vault' (default): Provide an existing app name (AppName) whose credentials are stored in the local secret vault (e.g., GraphEmailAppLocalStore). The function retrieves the AppId, TenantId, and certificate thumbprint automatically. 2. 'Manual': Provide the AppId, TenantId, and certificate thumbprint yourself, bypassing the vault. In both cases, the function obtains an OAuth2 token (via MSAL.PS) using the specified certificate and uses the Microsoft Graph 'sendMail' endpoint to deliver the message. .PARAMETER AppName [Vault Parameter Set Only] The name of the pre-created Microsoft Graph Email App (stored in GraphEmailAppLocalStore). Used only if the 'Vault' parameter set is chosen. The function retrieves the AppId, TenantId, and certificate thumbprint from the vault entry. .PARAMETER AppId [Manual Parameter Set Only] The Azure AD application (client) ID to use for sending the email. Must be used together with TenantId and CertThumbprint in the 'Manual' parameter set. .PARAMETER TenantId [Manual Parameter Set Only] The Azure AD tenant ID (GUID or domain name). Must be used together with AppId and CertThumbprint in the 'Manual' parameter set. .PARAMETER CertThumbprint [Manual Parameter Set Only] The certificate thumbprint (in Cert:\CurrentUser\My) used for authenticating as the Azure AD app. Must be used together with AppId and TenantId in the 'Manual' parameter set. .PARAMETER To The email address of the recipient. .PARAMETER FromAddress The email address of the sender who is authorized to send email as configured in the Graph Email App. .PARAMETER Subject The subject line of the email. .PARAMETER EmailBody The body text of the email. .PARAMETER AttachmentPath An array of file paths for any attachments to include in the email. Each path must exist as a leaf file. .PARAMETER VaultName [Vault Parameter Set Only] The name of the vault to retrieve the GraphEmailApp object. Default is 'GraphEmailAppLocalStore'. .EXAMPLE # Using the 'Vault' parameter set Send-TkEmailAppMessage -AppName "GraphEmailApp" -To "recipient@example.com" -FromAddress "sender@example.com" ` -Subject "Test Email" -EmailBody "This is a test email." Retrieves the app's credentials (AppId, TenantId, CertThumbprint) from the local vault under the secret name "GraphEmailApp" and sends an email. .EXAMPLE # Using the 'Manual' parameter set Send-TkEmailAppMessage -AppId "00000000-1111-2222-3333-444444444444" -TenantId "contoso.onmicrosoft.com" ` -CertThumbprint "AABBCCDDEEFF11223344556677889900" -To "recipient@example.com" -FromAddress "sender@example.com" ` -Subject "Manual Email" -EmailBody "Hello from Manual!" Uses the provided AppId, TenantId, and CertThumbprint directly (no vault) to obtain a token and send an email. .NOTES - This function requires the Microsoft.Graph, SecretManagement, SecretManagement.JustinGrote.CredMan, and MSAL.PS modules to be installed (handled automatically via Initialize-TkModuleEnv). - For the 'Vault' parameter set, the local vault secret must store JSON properties including AppId, TenantID, and CertThumbprint. - Refer to https://learn.microsoft.com/en-us/graph/outlook-send-mail for details on sending mail via Microsoft Graph. #> function Send-TkEmailAppMessage { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium', DefaultParameterSetName = 'Vault')] param( # Use the vault-based approach (default) [Parameter( ParameterSetName = 'Vault', Mandatory = $true, HelpMessage = 'The name of the pre-created Email App from the vault.' )] [ValidateNotNullOrEmpty()] [string] $AppName, # Use the manual approach (no vault) [Parameter( ParameterSetName = 'Manual', Mandatory = $true, HelpMessage = 'Manually specify the App (Client) ID.' )] [ValidateNotNullOrEmpty()] [string] $AppId, [Parameter( ParameterSetName = 'Manual', Mandatory = $true, HelpMessage = 'Manually specify the Azure AD tenant (GUID or domain).' )] [ValidateNotNullOrEmpty()] [string] $TenantId, [Parameter( ParameterSetName = 'Manual', Mandatory = $true, HelpMessage = 'Manually specify the certificate thumbprint in Cert:\CurrentUser\My.' )] [ValidateNotNullOrEmpty()] [string] $CertThumbprint, # Common parameters for both parameter sets [Parameter( Mandatory = $true, ParameterSetName = 'Vault', HelpMessage = 'The email address of the recipient.' )] [Parameter( Mandatory = $true, ParameterSetName = 'Manual', HelpMessage = 'The email address of the recipient.' )] [ValidateNotNullOrEmpty()] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $To, [Parameter( Mandatory = $true, ParameterSetName = 'Vault', HelpMessage = 'The email address of the sender.' )] [Parameter( Mandatory = $true, ParameterSetName = 'Manual', HelpMessage = 'The email address of the sender.' )] [ValidateNotNullOrEmpty()] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $FromAddress, [Parameter( Mandatory = $true, ParameterSetName = 'Vault', HelpMessage = 'The subject line of the email.' )] [Parameter( Mandatory = $true, ParameterSetName = 'Manual', HelpMessage = 'The subject line of the email.' )] [ValidateNotNullOrEmpty()] [string] $Subject, [Parameter( Mandatory = $true, ParameterSetName = 'Vault', HelpMessage = 'The body text of the email.' )] [Parameter( Mandatory = $true, ParameterSetName = 'Manual', HelpMessage = 'The body text of the email.' )] [ValidateNotNullOrEmpty()] [string] $EmailBody, [Parameter( Mandatory = $false, ParameterSetName = 'Vault', HelpMessage = 'The path to the attachment file.' )] [Parameter( Mandatory = $false, ParameterSetName = 'Manual', HelpMessage = 'The path to the attachment file.' )] [ValidateNotNullOrEmpty()] [ValidateScript({ Test-Path $_ -PathType 'Leaf' })] [string[]] $AttachmentPath, [Parameter( Mandatory = $false, ParameterSetName = 'Vault', HelpMessage = 'Vault name to retrieve the GraphEmailApp object.' )] [string] $VaultName = 'GraphEmailAppLocalStore' ) begin { if (!($script:LogString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###########################################################' # If manual parameter set: if ($PSCmdlet.ParameterSetName -eq 'Manual') { $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertThumbprint } -ErrorAction Stop # TODO Confirm this object is not needed elsewhere $GraphEmailApp = @{ AppId = $AppId CertThumbprint = $CertThumbprint TenantID = $TenantId CertExpires = $cert.NotAfter } $Tenant = $TenantId } elseif ($PSCmdlet.ParameterSetName -eq 'Vault') { # Install and import the Microsoft.Graph module. Tested: 1.22.0 $PublicMods = ` 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan' $PublicVers = ` '1.1.2', '1.0.0', '4.37.0.0' $params1 = @{ PublicModuleNames = $PublicMods PublicRequiredVersions = $PublicVers Scope = 'CurrentUser' } Initialize-TkModuleEnv @params1 # If a GraphEmailApp object was not passed in, attempt to retrieve it from the local machine if ($AppName) { try { # Step 7: # Define the application Name and Encrypted File Paths. $Auth = Get-Secret -Name "CN=$AppName" -Vault $VaultName -AsPlainText -ErrorAction Stop $authObj = $Auth | ConvertFrom-Json $GraphEmailApp = $authObj $AppId = $GraphEmailApp.AppId $CertThumbprint = $GraphEmailApp.CertThumbprint $Tenant = $GraphEmailApp.TenantID $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertThumbprint } -ErrorAction Stop } catch { Write-Error $_.Exception.Message } } # End Region If } if (!$GraphEmailApp) { throw 'GraphEmailApp object not found. Please specify the GraphEmailApp object or provide the AppName and RedirectUri parameters.' } # End Region If # Instantiate the required variables for retrieving the token. # Retrieve the self-signed certificate from the CurrentUser's certificate store if (!($cert)) { throw "Certificate with thumbprint $CertThumbprint not found in CurrentUser's certificate store" } # End Region If Write-AuditLog 'The Certificate:' Write-AuditLog $CertThumbprint Write-AuditLog "will expire on $($GraphEmailApp.CertExpires)" Write-AuditLog -Message 'Retrieved Certificate with thumbprint:' Write-AuditLog "$CertThumbprint" } # End Region Begin Process { # https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#second-case-access-token-request-with-a-certificate # Authenticate with Azure AD and obtain an access token for the Microsoft Graph API using the certificate $MSToken = Get-TkMsalToken ` -ClientCertificate $Cert ` -ClientId $AppId ` -TenantId $Tenant ` -ErrorAction Stop $authHeader = @{Authorization = "Bearer $MSToken" } # Set up the request URL $url = "https://graph.microsoft.com/v1.0/users/$($FromAddress)/sendMail" # Build the message body # Add a "from" field to the message object in $Message $FromField = @{ emailAddress = @{ address = "$($FromAddress)" } } $Message = @{ message = @{ subject = "$Subject" body = @{ contentType = 'text' content = "$EmailBody" } toRecipients = @( @{ emailAddress = @{ address = "$To" } } ) from = $FromField } } if ($AttachmentPath) { Write-AuditLog -Message 'Attachments found. Processing...' $Message.message.attachments = @() foreach ($Path in $AttachmentPath) { $attachmentName = (Split-Path -Path $Path -Leaf) $attachmentBytes = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($Path)) $attachment = @{ '@odata.type' = '#microsoft.graph.fileAttachment' 'Name' = $attachmentName 'ContentBytes' = $attachmentBytes } $Message.message.attachments += $attachment } } $jsonMessage = $message | ConvertTo-Json -Depth 4 $body = $jsonMessage Write-AuditLog -Message 'Processed message body. Ready to send email.' } End { try { # Send the email message using the Invoke-RestMethod cmdlet Write-AuditLog 'Sending email via Microsoft Graph.' Write-AuditLog "To : $To" Write-AuditLog "From : $FromAddress" Write-AuditLog "Attachments To Send : $(($Message.message.attachments).Count)" $shouldProcessOperation = 'Send-TkEmailAppMessage' $shouldProcessTarget = "Sender: $FromAddress, Recipient: $To Attachments: $(($Message.message.attachments).Count)" if ($PSCmdlet.ShouldProcess($shouldProcessTarget, $shouldProcessOperation)) { [void](Invoke-RestMethod -Headers $authHeader -Uri $url -Body $body -Method POST -ContentType 'application/json' -ErrorAction Stop) } Write-AuditLog -EndFunction } catch { throw } } # End Region End } #EndRegion '.\Public\Send-TkEmailAppMessage.ps1' 314 |