GraphAppToolkit.psm1
#Region '.\Classes\TkEmailAppParams.ps1' -1 class TkEmailAppParams { [string]$AppId [string]$Id [string]$AppName [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]$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.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' 44 #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]$ClientId [string]$ConsentUrl [string]$PermissionSet [string]$Permissions [string]$TenantId # Constructor TkMemPolicyManagerAppParams ( [string]$AppId, [string]$AppName, [string]$CertThumbprint, [string]$ClientId, [string]$ConsentUrl, [string]$PermissionSet, [string]$Permissions, [string]$TenantId ) { $this.AppId = $AppId $this.AppName = $AppName $this.CertThumbprint = $CertThumbprint $this.ClientId = $ClientId $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 function Connect-TkMsService { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [Parameter( HelpMessage = 'Connect to Microsoft Graph.' )] [Switch] $MgGraph, [Parameter( HelpMessage = '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) { if ($PSCmdlet.ShouldProcess( 'Microsoft Graph', 'Connecting with scopes: Application.ReadWrite.All, DelegatedPermissionGrant.ReadWrite.All, Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory' )) { 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 = @( 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' ) $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) { $null = $useExisting = Read-Host 'Do you want to use the existing Microsoft Graph session? (Y/N)' if ($useExisting -match '^[Yy]') { 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 -Scopes ` 'Application.ReadWrite.All', ` 'DelegatedPermissionGrant.ReadWrite.All', ` 'Directory.ReadWrite.All', ` 'RoleManagement.ReadWrite.Directory' ` -ErrorAction Stop Write-AuditLog 'Connected to Microsoft Graph.' } } else { # No valid session, so just connect Write-AuditLog 'No valid Microsoft Graph session found. Connecting...' Connect-MgGraph -Scopes ` 'Application.ReadWrite.All', ` 'DelegatedPermissionGrant.ReadWrite.All', ` 'Directory.ReadWrite.All', ` 'RoleManagement.ReadWrite.Directory' ` -ErrorAction Stop 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) { if ($PSCmdlet.ShouldProcess( 'Exchange Online', 'Connecting to ExchangeOnline using modern authentication pop-up.' )) { try { # 1) Attempt to see if we have a valid Exchange session $exoIsValid = $false try { $Org = (Get-OrganizationConfig -ErrorAction Stop).Identity $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$Org`n" $null = $useExisting = Read-Host 'Do you want to use the existing Exchange Online session? (Y/N)' if ($useExisting -match '^[Yy]') { 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' 144 #Region '.\Private\ConvertTo-ParameterSplat.ps1' -1 function ConvertTo-ParameterSplat { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [PSObject]$InputObject ) process { $splatScript = "`$params = @{`n" $InputObject.psobject.Properties | ForEach-Object { $value = $_.Value if ($value -is [string]) { $value = "`"$value`"" } $splatScript += " $($_.Name) = $value`n" } $splatScript += "}" Write-Output $splatScript } } #EndRegion '.\Private\ConvertTo-ParameterSplat.ps1' 21 #Region '.\Private\Initialize-TkAppAuthCertificate.ps1' -1 <# .SYNOPSIS Retrieves or creates a self-signed certificate in the specified store. .DESCRIPTION The Initialize-TkAppAuthCertificate function either retrieves a certificate by thumbprint from the specified store or creates a new self-signed certificate if no thumbprint is provided. It returns a PSCustomObject containing the certificate's thumbprint, expiration date, and an optional AppName (to maintain compatibility with existing usage). .PARAMETER Thumbprint The thumbprint of the certificate to retrieve. If omitted, a new self-signed certificate is created. .PARAMETER AppName An optional name for the application or usage context of this certificate. This is used to populate the "AppName" property in the returned object if needed. .PARAMETER Subject The certificate subject, for example: "CN=MyNewAppCert". Defaults to "CN=DefaultSelfSignedCert" if no thumbprint is provided. .PARAMETER CertStoreLocation The certificate store path (e.g., "Cert:\CurrentUser\My" or "Cert:\LocalMachine\My"). Defaults to "Cert:\CurrentUser\My". .EXAMPLE # Retrieve an existing cert by thumbprint Initialize-TkAppAuthCertificate -Thumbprint "9B8B40C5F148B710AD5C0E5CC8D0B71B5A30DB0C" .EXAMPLE # Create a new self-signed cert for a specific application name Initialize-TkAppAuthCertificate -AppName "MyGraphApp" -Subject "CN=MyGraphAppCert" Returns an object containing AppName, CertThumbprint, and expiration info. .OUTPUTS PSCustomObject with: - CertThumbprint - CertExpires - AppName (if provided) .NOTES Author: DrIOSx Requires: Write-AuditLog The user must have permission to create or retrieve certificates from the specified store. #> 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 if ($PSCmdlet.ShouldProcess($Subject, "Create new self-signed certificate in $CertStoreLocation")) { $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 { throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Initialize-TkAppAuthCertificate.ps1' 118 #Region '.\Private\Initialize-TkAppSpRegistration.ps1' -1 function Initialize-TkAppSpRegistration { [CmdletBinding()] param( [Parameter( Mandatory = $true, HelpMessage = 'The App Registration object.' )] $AppRegistration, [Parameter( Mandatory = $true, HelpMessage = 'The Graph Service Principal Id.' )] [PSCustomObject[]]$RequiredResourceAccessList, [Parameter( Mandatory = $true, HelpMessage = 'The Azure context.' )] [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 = 'Auth method (placeholder). Currently only "Certificate" is used.' )] [ValidateSet('Certificate', 'ClientSecret', 'ManagedIdentity', 'None')] [string]$AuthMethod = 'Certificate', [Parameter( Mandatory = $false, HelpMessage = 'Certificate thumbprint if using Certificate-based auth.' )] [string]$CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = 'The certificate store location (e.g., "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'." } } process { try { # 1. If using certificate auth, retrieve the certificate $Cert = $null 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." } } # 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.' } $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 $($ClientSp.DisplayName): $combinedScopes" $Params = @{ ClientId = $ClientSp.Id ConsentType = 'AllPrincipals' ResourceId = $ResourceId Scope = $combinedScopes } [void](New-MgOauth2PermissionGrant -BodyParameter $Params -Confirm:$false -ErrorAction Stop) Write-AuditLog "Admin consent granted for $ResourceId with scopes: $combinedScopes." Start-Sleep -Seconds 2 $i++ } # 5. Build the admin consent URL $adminConsentUrl = 'https://login.microsoftonline.com/' + $Context.TenantId + '/adminconsent?client_id=' + $AppRegistration.AppId Write-Verbose 'Please go to the following URL in your browser to provide admin consent:' -Verbose Write-AuditLog "`n$adminConsentUrl`n" -Severity information # 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$connectGraph`n" -Severity Information } 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 { throw } } end { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Initialize-TkAppSpRegistration.ps1' 132 #Region '.\Private\Initialize-TkModuleEnv.ps1' -1 <# .SYNOPSIS Installs or updates required PowerShell modules, with support for stable or pre-release versions. .DESCRIPTION The Initialize-TkModuleEnv function handles module installation and importing in a flexible manner. It checks for PowerShellGet (and updates it if needed), adjusts the function limit if the Microsoft.Graph module is included, and can install modules for either the CurrentUser or AllUsers scope. It supports both stable (Public) and pre-release modules, and optionally imports specific modules by name. Logging is handled via Write-AuditLog, and administrative privileges are required for certain operations (e.g., installing modules for AllUsers). .PARAMETER PublicModuleNames An array of stable module names to install when using the 'Public' parameter set. .PARAMETER PublicRequiredVersions An array of required stable module versions corresponding to each name in PublicModuleNames. .PARAMETER PrereleaseModuleNames An array of pre-release module names to install when using the 'Prerelease' parameter set. .PARAMETER PrereleaseRequiredVersions An array of required pre-release module versions corresponding to each name in PrereleaseModuleNames. .PARAMETER Scope Specifies whether to install the modules for the CurrentUser or AllUsers. Accepts 'CurrentUser' or 'AllUsers'. Requires administrative privileges for 'AllUsers'. .PARAMETER ImportModuleNames An optional list of modules to selectively import after installation. If not specified, all installed modules are imported. .EXAMPLE Initialize-TkModuleEnv -PublicModuleNames "PsNmap", "Microsoft.Graph" -PublicRequiredVersions "1.3.1","1.23.0" -Scope AllUsers Installs PsNmap and Microsoft.Graph in the AllUsers scope with the specified versions. .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. .INPUTS None. You cannot pipe input into this function. .OUTPUTS None. This function does not return objects to the pipeline. .NOTES Author: DrIOSx Requires: Write-AuditLog, Test-IsAdmin - This function checks for and updates PowerShellGet if needed. - It sets the function limit to 8192 if the Microsoft.Graph module is included and PowerShell is 5.1. - If the user lacks administrative privileges but tries to install to AllUsers, it throws an error. #> function Initialize-TkModuleEnv { [CmdletBinding(DefaultParameterSetName = 'Public')] param( [Parameter( ParameterSetName = 'Public', Mandatory )] [string[]] $PublicModuleNames, [Parameter( ParameterSetName = 'Public', Mandatory )] [string[]] $PublicRequiredVersions, [Parameter( ParameterSetName = 'Prerelease', Mandatory )] [string[]] $PrereleaseModuleNames, [Parameter( ParameterSetName = 'Prerelease', Mandatory )] [string[]] $PrereleaseRequiredVersions, [ValidateSet('AllUsers', 'CurrentUser')] [string] $Scope, [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 } } # 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 } 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...' [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 } } # 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." } } # 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 foreach ($m in $modules) { $requiredVersion = $versions[$modules.IndexOf($m)] $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." Install-Module $m -Scope $Scope -RequiredVersion $requiredVersion -AllowPrerelease:$prerelease -ErrorAction Stop Write-AuditLog "$m module successfully installed!" if ($SelectiveImports) { foreach ($ModName in $SelectiveImports) { Write-AuditLog "Importing $ModName." Import-Module $ModName -ErrorAction Stop Write-AuditLog "Successfully imported $ModName." } } else { Write-AuditLog "Importing $m" Import-Module $m -ErrorAction Stop Write-AuditLog "Successfully imported $m" } } else { Write-AuditLog "$m v$($installed.Version) exists." if ($SelectiveImports) { foreach ($ModName in $SelectiveImports) { Write-AuditLog "Importing SubModule: $ModName." Import-Module $ModName -ErrorAction Stop Write-AuditLog "Imported SubModule: $ModName." } } else { Write-AuditLog "Importing $m" Import-Module $m -ErrorAction Stop Write-AuditLog "Imported $m" } } } } catch { throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Private\Initialize-TkModuleEnv.ps1' 198 #Region '.\Private\New-TkAppName.ps1' -1 function New-TkAppName { [CmdletBinding()] 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 = "GraphApp", [Parameter( Mandatory=$false, HelpMessage='Optional user email to append "As-[username]" suffix.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $UserId ) 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" } # Example final: GraphToolKit-MSN-GraphApp-MyDomain-As-helpDesk # But you can do anything you want with $env:USERDNSDOMAIN, etc. $domainSuffix = $env:USERDNSDOMAIN if (-not $domainSuffix) { # fallback if not set $domainSuffix = "MyDomain" } $appName = "GraphToolKit-$Prefix-$ScenarioName-$domainSuffix$userSuffix" Write-AuditLog "Returning app name: $appName" return $appName } catch { throw } finally { Write-AuditLog -EndFunction } } } #EndRegion '.\Private\New-TkAppName.ps1' 57 #Region '.\Private\New-TkAppRegistration.ps1' -1 <# .SYNOPSIS Creates a new enterprise application registration in Azure AD with a specified certificate. .DESCRIPTION The New-TkAppRegistration function creates a new Azure AD application registration (sometimes called an enterprise app) using Microsoft Graph. It sets the sign-in audience, attaches a certificate for authentication, and configures one or more application permission IDs for the specified resource (e.g., Microsoft Graph). Logging is handled by the Write-AuditLog function, and the newly created application object is returned. .PARAMETER DisplayName The display name for the new app registration. .PARAMETER CertThumbprint The thumbprint of the certificate used to secure this app, located in the CurrentUser certificate store. .PARAMETER ResourceAppId The Azure AD resource (for example, the Microsoft Graph app ID: 00000003-0000-0000-c000-000000000000). .PARAMETER PermissionIds One or more permission IDs (application permissions) to grant for the resource. For example, "Mail.Send". .PARAMETER SignInAudience The sign-in audience for the app registration. Valid values are "AzureADMyOrg", "AzureADMultipleOrgs", and "AzureADandPersonalMicrosoftAccount". Defaults to "AzureADMyOrg". .EXAMPLE PS C:\> New-TkAppRegistration -DisplayName "MyEnterpriseApp" -CertThumbprint "AABBCCDDEEFF1122" -ResourceAppId "00000003-0000-0000-c000-000000000000" -PermissionIds "Mail.Send" Creates a new Azure AD application named "MyEnterpriseApp", attaches the specified certificate, targets the Microsoft Graph resource (AppId 00000003-0000-0000-c000-000000000000), and grants the "Mail.Send" permission. .INPUTS None. You cannot pipe input to this function. .OUTPUTS Microsoft.Graph.PowerShell.Models.MicrosoftGraphApplication Returns the newly created Azure AD application registration object. .NOTES Author: DrIOSx Requires: Microsoft.Graph PowerShell module, Write-AuditLog function The user must have permissions in Azure AD to create and manage applications. #> function New-TkAppRegistration { [CmdletBinding()] param ( [Parameter( Mandatory = $true, HelpMessage = ` 'The display name for the new app registration.' )] [string] $DisplayName, [Parameter( Mandatory = $false, HelpMessage = ` 'Pass an array of MicrosoftGraphRequiredResourceAccess objects for multi-resource mode.' )] [Microsoft.Graph.PowerShell.Models.MicrosoftGraphRequiredResourceAccess[]] $RequiredResourceAccessList, [Parameter( HelpMessage = ` 'The sign-in audience for the app registration.' )] [ValidateSet('AzureADMyOrg', 'AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount')] [string] $SignInAudience = 'AzureADMyOrg', [Parameter( Mandatory = $true, HelpMessage = ` 'The thumbprint of the certificate used to secure this app.' )] [string] $CertThumbprint, [Parameter( Mandatory = $false, HelpMessage = ` 'The certificate store location (e.g., "Cert:\CurrentUser\My").' )] [string] $CertStoreLocation = 'Cert:\CurrentUser\My', [Parameter( Mandatory = $false, HelpMessage = "A descriptive note about this app's purpose or usage." )] [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." } # 2) Create the new app registration $AppRegistration = New-MgApplication ` -DisplayName $DisplayName ` -Notes $Notes ` -SignInAudience $SignInAudience ` -RequiredResourceAccess $RequiredResourceAccessList ` -AdditionalProperties @{} ` -KeyCredentials @( @{ Type = 'AsymmetricX509Cert' Usage = 'Verify' Key = $Cert.RawData } ) 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' 127 #Region '.\Private\New-TkExchangeEmailAppPolicy.ps1' -1 function New-TkExchangeEmailAppPolicy { [CmdletBinding()] param ( [Parameter( Mandatory = $true, HelpMessage = 'The application registration object.' )] [PSObject] $AppRegistration, [Parameter( Mandatory = $true, HelpMessage = 'The Mail Enabled Sending Group.' )] [string] $MailEnabledSendingGroup ) # Begin Logging if (!($script:LogString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { 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' -ErrorAction Stop Write-AuditLog -Message "Created Exchange Application policy for $($MailEnabledSendingGroup)." } catch { throw } Write-AuditLog -EndFunction } #EndRegion '.\Private\New-TkExchangeEmailAppPolicy.ps1' 36 #Region '.\Private\New-TkRequiredResourcePermissionObject.ps1' -1 function New-TkRequiredResourcePermissionObject { [CmdletBinding(DefaultParameterSetName = 'Default')] param ( [Parameter( Mandatory = $false, HelpMessage = 'Application (app-only) permissions for Microsoft Graph.' )] [string[]] $GraphPermissions = @('Mail.Send'), [Parameter( Mandatory = $false, HelpMessage = 'Scenario app version.', ParameterSetName = 'Scenario' )] [ValidateSet('365Audit')] [string] $Scenario ) process { 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'" # 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) { throw 'Microsoft Graph Service Principal not found (by display name).' } 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 } #Find-MgGraphPermission -PermissionType Application -All | 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 { throw "No Graph permissions found for '$($GraphPermissions -join ', ')'. Check the permission names and try again." } } # 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] $spRra = $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 { throw } finally { Write-AuditLog -EndFunction } } } #EndRegion '.\Private\New-TkRequiredResourcePermissionObject.ps1' 99 #Region '.\Private\Set-TkJsonSecret.ps1' -1 function Set-TkJsonSecret { [CmdletBinding()] param( [Parameter( Mandatory=$true,HelpMessage='The name under which to store the secret.' )] [string] $Name, [Parameter( Mandatory=$true, HelpMessage='The object to convert to JSON and store.' )] [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){ Write-AuditLog -Message "Overwriting existing secret '$Name' in vault '$VaultName'." Remove-Secret -Name $Name -Vault $VaultName -Confirm:$false -ErrorAction Stop } else{ Write-AuditLog -Message "Secret '$Name' already exists. Remove it or specify -Overwrite to overwrite." -Severity Warning return } } $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' 68 #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', and 'Error'. Defaults to 'Information'. .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 (Information) 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) $UserInput = Read-Host 'Warning encountered! Do you want to continue? (Y/N)' if ($UserInput -eq 'N') { throw 'Script execution stopped by user.' } } '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) -InformationAction Continue } } } 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' 214 #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. .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(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 ) if (!($script:LogString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } try { 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)" $group = New-DistributionGroup @groupParams Write-AuditLog -Message "Distribution group created:`n$($group | Out-String)" return $group } catch { throw } finally { Write-AuditLog -EndFunction } } #EndRegion '.\Public\New-MailEnabledSendingGroup.ps1' 116 #Region '.\Public\Publish-TkEmailApp.ps1' -1 <# .SYNOPSIS Deploys a new Microsoft Graph Email app and associates it with a certificate for app-only authentication. .DESCRIPTION This cmdlet deploys a new Microsoft Graph Email app and associates it with a certificate for app-only authentication. It requires an AppPrefix for the app, an optional CertThumbprint, an AuthorizedSenderUserName, and a MailEnabledSendingGroup. Additionally, you can specify a KeyExportPolicy for the certificate, control how secrets are stored via VaultName and OverwriteVaultSecret, and optionally return a parameter splat instead of a PSCustomObject. .PARAMETER AppPrefix A unique prefix for the Graph Email App to initialize. Ensure it is used consistently for grouping purposes (2-4 alphanumeric characters). .PARAMETER AuthorizedSenderUserName The username of the authorized sender. .PARAMETER MailEnabledSendingGroup The mail-enabled group to which the sender belongs. This will be used to assign app policy restrictions. .PARAMETER CertThumbprint An optional parameter indicating the thumbprint of the certificate to be retrieved. If not specified, a self-signed certificate will be generated. .PARAMETER KeyExportPolicy Specifies the key export policy for the newly created certificate. Valid values are 'Exportable' or 'NonExportable'. Defaults to 'NonExportable'. .PARAMETER VaultName If specified, the name of the vault to store the app's credentials. Otherwise, defaults to 'GraphEmailAppLocalStore'. .PARAMETER OverwriteVaultSecret If specified, the function overwrites an existing secret in the vault if it already exists. .PARAMETER ReturnParamSplat If specified, returns the parameter splat for use in other functions instead of the PSCustomObject. .EXAMPLE PS C:\> Publish-TkEmailApp -AppPrefix "ABC" -AuthorizedSenderUserName "jdoe@example.com" -MailEnabledSendingGroup "GraphAPIMailGroup@example.com" -CertThumbprint "AABBCCDDEEFF11223344556677889900" .INPUTS None .OUTPUTS By default, returns a PSCustomObject containing details such as AppId, CertThumbprint, TenantID, and CertExpires. If -ReturnParamSplat is specified, returns the parameter splat instead. .NOTES 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' #> function Publish-TkEmailApp { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] param( [Parameter( Mandatory = $true, 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, [Parameter( Mandatory = $true, HelpMessage = 'The username of the authorized sender.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $AuthorizedSenderUserName, [Parameter( Mandatory = $true, HelpMessage = 'The Mail Enabled Sending Group.' )] [ValidatePattern('^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')] [string] $MailEnabledSendingGroup, [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 ) begin { 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 # 2) Connect to both Graph and Exchange Connect-TkMsService -MgGraph -ExchangeOnline # 3) Verify if the user (authorized sender) exists $user = Get-MgUser -Filter "Mail eq '$AuthorizedSenderUserName'" if (-not $user) { throw "User '$AuthorizedSenderUserName' not found in the tenant." } $Context = Get-MgContext -ErrorAction Stop # 4) Build the app context (Mail.Send permission, etc.) $AppSettings = New-TkRequiredResourcePermissionObject -GraphPermissions 'Mail.Send' $appName = New-TkAppName ` -Prefix $AppPrefix ` -ScenarioName 'AuditGraphEmail' ` -UserId $AuthorizedSenderUserName # Add relevant properties to $AppSettings $AppSettings | Add-Member -NotePropertyName 'User' -NotePropertyValue $user $AppSettings | Add-Member -NotePropertyName 'AppName' -NotePropertyValue $appName # 5) Create or retrieve the certificate $CertDetails = Initialize-TkAppAuthCertificate ` -AppName $AppSettings.AppName ` -Thumbprint $CertThumbprint ` -Subject "CN=$($AppSettings.AppName)" ` -KeyExportPolicy $KeyExportPolicy ` -ErrorAction Stop } catch { throw } } process { $proposedObject = [PSCustomObject]@{ ProposedAppName = $AppSettings.AppName 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 "`n$($proposedObject | Format-List)`n" $permissionsObject = [PSCustomObject]@{ Graph = 'Mail.Send' } if ($PSCmdlet.ShouldProcess( "GraphEmailApp '$($AppSettings.AppName)'", 'Creating & configuring a new Graph Email App in Azure AD' )) { try { $Notes = @" Graph Email App for: $AuthorizedSenderUserName Restricted to group: '$MailEnabledSendingGroup'. Certificate Thumbprint: $($CertDetails.CertThumbprint) Certificate Expires: $($CertDetails.CertExpires) Tenant ID: $($Context.TenantId) App Permissions: $($permissionsObject.Graph) Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim()) Client Hostname: $env:COMPUTERNAME "@ # 6) Register the new enterprise app for Graph $appRegistration = New-TkAppRegistration ` -DisplayName $AppSettings.AppName ` -CertThumbprint $CertDetails.CertThumbprint ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -SignInAudience 'AzureADMyOrg' ` -Notes $Notes ` -ErrorAction Stop # 7) Configure the service principal, permissions, etc. $ConsentUrl = Initialize-TkAppSpRegistration ` -AppRegistration $appRegistration ` -Context $Context ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -Scopes $permissionsObject ` -AuthMethod 'Certificate' ` -CertThumbprint $CertDetails.CertThumbprint ` -ErrorAction Stop [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 [void](New-TkExchangeEmailAppPolicy -AppRegistration $appRegistration -MailEnabledSendingGroup $MailEnabledSendingGroup) # 9) Build final output object $output = [PSCustomObject]@{ AppId = $appRegistration.AppId Id = $appRegistration.Id AppName = "CN=$($AppSettings.AppName)" 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 } $graphEmailApp = [TkEmailAppParams]::new( $output.AppId, $output.Id, $output.AppName, $output.AppRestrictedSendGroup, $output.CertExpires, $output.CertThumbprint, $output.ConsentUrl, $output.DefaultDomain, $output.SendAsUser, $output.SendAsUserEmail, $output.TenantID ) # 10) Store it as JSON in the vault $secretName = "CN=$($AppSettings.AppName)" $savedSecretName = Set-TkJsonSecret -Name $secretName -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret Write-AuditLog "Secret '$savedSecretName' saved to vault '$VaultName'." } catch { throw } } else { Write-AuditLog 'User elected not to create or configure the Graph Email App. (ShouldProcess => false).' } } end { if ($ReturnParamSplat) { return ($graphEmailApp | ConvertTo-ParameterSplat) } else { return $graphEmailApp } Write-AuditLog -EndFunction } } #EndRegion '.\Public\Publish-TkEmailApp.ps1' 260 #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. .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. .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: 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' #> function Publish-TkM365AuditApp { [CmdletBinding(SupportsShouldProcess = $true, 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 ) begin { <# $uniqueSuffix = [System.Guid]::NewGuid().ToString('N').Substring(0, 4) $TwoToFourLetterCompanyAbbreviation = "CS$($uniqueSuffix.Substring(0,2))" Publish-TkM365AuditApp -AppPrefix $TwoToFourLetterCompanyAbbreviation -ReturnParamSplat #> if (-not $script:LogString) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###############################################' Write-AuditLog 'Initializing M365 Audit App publication process...' # 1) Connect to Graph so we can query permissions & create the app Connect-TkMsService -MgGraph } process { try { # 2) Define read-only vs. read-write sets $graphReadOnly = @( 'AppCatalog.ReadWrite.All', #'AuditLog.Read.All', 'Channel.Delete.All', 'ChannelMember.ReadWrite.All', 'ChannelSettings.ReadWrite.All', #'DeviceManagementApps.Read.All', #'DeviceManagementApps.ReadWrite.All', #'DeviceManagementConfiguration.Read.All', #'DeviceManagementConfiguration.ReadWrite.All', #'DeviceManagementManagedDevices.Read.All', #'DeviceManagementManagedDevices.ReadWrite.All', 'Directory.Read.All', #'Group.Read.All', 'Group.ReadWrite.All', 'Organization.Read.All', 'Policy.Read.All', 'Domain.Read.All' #'Policy.Read.ConditionalAccess', #'RoleManagement.Read.Directory', 'TeamSettings.ReadWrite.All' #'TeamSettings.Read.All', #'UserAuthenticationMethod.Read.All', 'User.Read.All' ) #$graphReadWrite = @('Directory.ReadWrite.All') # add more if needed # For SharePoint, only 'Sites.Read.All' for read-only, # 'Sites.FullControl.All' for read-write $sharePointReadOnly = @('Sites.Read.All') $sharePointReadWrite = @('Sites.FullControl.All') # For Exchange, typically 'Exchange.ManageAsApp' suffices in read-only mode # Add more if you need read-write Exchange perms $exchangeReadOnly = @('Exchange.ManageAsApp') # Decide which sets to use $graphPerms = $graphReadOnly #if ($ReadWrite) { $graphReadOnly + $graphReadWrite } else { $graphReadOnly } $sharePointPerms = $sharePointReadOnly + $sharePointReadWrite $exchangePerms = $exchangeReadOnly $permissionsObject = [PSCustomObject]@{ Graph = $graphPerms SharePoint = $sharePointPerms Exchange = $exchangePerms } Write-AuditLog "Graph Perms: $($graphPerms -join ', ')" Write-AuditLog "SharePoint Perms: $($sharePointPerms -join ', ')" Write-AuditLog "Exchange Perms: $($exchangePerms -join ', ')" $Context = Get-MgContext -ErrorAction Stop # 3) Gather the resource access objects (GUIDs) for all these perms $AppSettings = New-TkRequiredResourcePermissionObject ` -GraphPermissions $graphPerms ` -Scenario '365Audit' ` -ErrorAction Stop # This returns an object with .RequiredResourceAccessList (the array # of MicrosoftGraphRequiredResourceAccess objects) plus .TenantId, etc. # 4) Generate the app name $appName = New-TkAppName -Prefix $AppPrefix -ScenarioName 'M365Audit' -ErrorAction Stop Write-AuditLog "Proposed new M365 Audit App name: $appName" # 5) Retrieve or create the certificate $CertDetails = Initialize-TkAppAuthCertificate ` -AppName $appName ` -Thumbprint $CertThumbprint ` -Subject "CN=$appName" ` -KeyExportPolicy $KeyExportPolicy ` -ErrorAction Stop Write-AuditLog "Certificate Thumbprint: $($CertDetails.CertThumbprint); Expires: $($CertDetails.CertExpires)." # Show user proposed config $proposed = [PSCustomObject]@{ ProposedAppName = $appName CertificateThumbprint = $CertDetails.CertThumbprint CertExpires = $CertDetails.CertExpires GraphPermissions = $graphPerms -join ', ' SharePointPermissions = $sharePointPerms -join ', ' ExchangePermissions = $exchangePerms -join ', ' } Write-AuditLog 'Proposed creation of a new M365 Audit App with the following properties:' Write-AuditLog "$($proposed | Format-List)" # 6) Create the app in one pass with all resources $Notes = @" Certificate Thumbprint: $($CertDetails.CertThumbprint) Certificate Expires: $($CertDetails.CertExpires) Tenant ID: $($Context.TenantId) Graph App Permissions: $($graphPerms -join ', ') SharePoint App Permissions: $($sharePointPerms -join ', ') Exchange App Permissions: $($exchangePerms -join ', ') Roles Assigned: 'Exchange Administrator', 'Global Reader' Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim()) Client Hostname: $env:COMPUTERNAME "@ if ($PSCmdlet.ShouldProcess($appName, 'Create and configure M365 Audit App in EntraAD')) { Write-AuditLog 'Creating new EntraAD application with all resource permissions...' $appRegistration = New-TkAppRegistration ` -DisplayName $appName ` -CertThumbprint $CertDetails.CertThumbprint ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -Notes $Notes ` -SignInAudience 'AzureADMyOrg' Write-AuditLog "App registered. Object ID = $($appRegistration.Id), ClientId = $($appRegistration.AppId)." # 7) Grant the oauth2 permissions to service principal $ConsentUrl = Initialize-TkAppSpRegistration ` -AppRegistration $appRegistration ` -Context $Context ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -Scopes $permissionsObject ` -AuthMethod 'Certificate' ` -CertThumbprint $CertDetails.CertThumbprint ` -ErrorAction Stop [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'" # 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 Write-AuditLog 'Appending Global Reader role to the app.' $globalReaderRole = Get-MgDirectoryRole -Filter "displayName eq 'Global Reader'" $body = @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$spObjectId" } New-MgDirectoryRoleMemberByRef ` -DirectoryRoleId $globalReaderRole.Id ` -BodyParameter $body # 8) Store final app info in the vault $output = [PSCustomObject]@{ AppName = $("CN=$appName") AppId = $appRegistration.AppId ObjectId = $appRegistration.Id TenantId = $context.TenantId CertThumbprint = $CertDetails.CertThumbprint CertExpires = $CertDetails.CertExpires ConsentUrl = $ConsentUrl MgGraphPermissions = "$($graphPerms)" SharePointPermissions = "$($sharePointPerms)" ExchangePermissions = "$($exchangePerms)" } $m365AuditApp = [TkM365AuditAppParams]::new( $output.AppName, $output.AppId, $output.ObjectId, $output.TenantId, $output.CertThumbprint, $output.CertExpires, $output.ConsentUrl, $output.MgGraphPermissions, $output.SharePointPermissions, $output.ExchangePermissions ) # Save to vault Set-TkJsonSecret -Name "CN=$appName" -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret Write-AuditLog "Saved app credentials 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' 286 #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. .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: 'Application.ReadWrite.All', 'DelegatedPermissionGrant.ReadWrite.All', 'Directory.ReadWrite.All', 'RoleManagement.ReadWrite.Directory' #> function Publish-TkMemPolicyManagerApp { [CmdletBinding(SupportsShouldProcess = $true, 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 ) 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 Connect-TkMsService -MgGraph $Context = Get-MgContext -ErrorAction Stop } catch { $line = $_.InvocationInfo.Line $lineNum = $_.InvocationInfo.ScriptLineNumber throw [System.Management.Automation.RuntimeException]::new( "Error in $($MyInvocation.MyCommand.Name) at line $lineNum`:`n'$line' - $($_.Exception.Message)", $_.Exception ) } } 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 = New-TkRequiredResourcePermissionObject -GraphPermissions $permissions # 3) Build an app name for scenario "MemPolicyManager" $appName = New-TkAppName -Prefix $AppPrefix -ScenarioName 'MemPolicyManager' # 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 $CertDetails = Initialize-TkAppAuthCertificate ` -AppName $AppSettings.AppName ` -Thumbprint $CertThumbprint ` -Subject "CN=$($AppSettings.AppName)" ` -KeyExportPolicy $KeyExportPolicy ` -ErrorAction Stop # 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)" # The big If: confirm with ShouldProcess $Notes = @" Certificate Thumbprint: $($CertDetails.CertThumbprint) Certificate Expires: $($CertDetails.CertExpires) Tenant ID: $($Context.TenantId) Graph App Permissions: $($permissions -join ', ') Read-Write Permissions: $(if ($ReadWrite) { 'ReadWrite' } else { 'Read-Only' }) Authorized Client IP: $((Invoke-WebRequest ifconfig.me/ip).Content.Trim()) Client Hostname: $env:COMPUTERNAME "@ if ($PSCmdlet.ShouldProcess("MemPolicyManager App '$($AppSettings.AppName)'", 'Create and configure a new MEM Policy Manager app in Azure AD?')) { # 6) Register the application (with the cert) $appRegistration = New-TkAppRegistration ` -DisplayName $AppSettings.AppName ` -CertThumbprint $CertDetails.CertThumbprint ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -SignInAudience 'AzureADMyOrg' ` -Notes $Notes ` -ErrorAction Stop # 7) Create the Service Principal & grant the permissions $ConsentUrl = Initialize-TkAppSpRegistration ` -AppRegistration $appRegistration ` -Context $Context ` -RequiredResourceAccessList $AppSettings.RequiredResourceAccessList ` -Scopes $permissionsObject ` -AuthMethod 'Certificate' ` -CertThumbprint $CertDetails.CertThumbprint ` -ErrorAction Stop [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 $output = [PSCustomObject]@{ AppId = $appRegistration.AppId AppName = "CN=$($AppSettings.AppName)" CertThumbprint = $CertDetails.CertThumbprint ClientId = $appRegistration.AppId ConsentUrl = $ConsentUrl PermissionSet = if ($ReadWrite) { 'ReadWrite' } else { 'ReadOnly' } Permissions = $permissions TenantId = $Context.TenantId } $auditObj = [TkMemPolicyManagerAppParams]::new( $output.AppId, $output.AppName, $output.CertThumbprint, $output.ClientId, $output.ConsentUrl, $output.PermissionSet, $output.Permissions, $output.TenantId ) # 9) Store as JSON secret $secretName = "CN=$($AppSettings.AppName)" $savedName = Set-TkJsonSecret -Name $secretName -InputObject $output -VaultName $VaultName -Overwrite:$OverwriteVaultSecret Write-AuditLog "Secret '$savedName' saved to vault '$VaultName'." # Return the final object (param-splat or normal) if ($ReturnParamSplat) { return $auditObj | ConvertTo-ParameterSplat } else { return $auditObj } } else { Write-AuditLog 'User elected not to create or configure the MEM Policy Manager App. (ShouldProcess => false).' } } catch { throw } } end { Write-AuditLog -EndFunction } } #EndRegion '.\Public\Publish-TkMemPolicyManagerApp.ps1' 256 #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. .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(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 ) begin { if (!($script:LogString)) { Write-AuditLog -Start } else { Write-AuditLog -BeginFunction } Write-AuditLog '###########################################################' # Install and import the Microsoft.Graph module. Tested: 1.22.0 $PublicMods = ` 'Microsoft.PowerShell.SecretManagement', 'SecretManagement.JustinGrote.CredMan', 'MSAL.PS' $PublicVers = ` '1.1.2', '1.0.0', '4.37.0.0' $params1 = @{ PublicModuleNames = $PublicMods PublicRequiredVersions = $PublicVers Scope = 'CurrentUser' } Initialize-TkModuleEnv @params1 # If manual parameter set: if ($PSCmdlet.ParameterSetName -eq 'Manual') { $cert = Get-ChildItem -Path Cert:\CurrentUser\My | Where-Object { $_.Thumbprint -eq $CertThumbprint } -ErrorAction Stop $GraphEmailApp = @{ AppId = $AppId CertThumbprint = $CertThumbprint TenantID = $TenantId CertExpires = $cert.NotAfter } } elseif ($PSCmdlet.ParameterSetName -eq 'Vault') { # 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 "$AppName" -Vault GraphEmailAppLocalStore -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 { # Authenticate with Azure AD and obtain an access token for the Microsoft Graph API using the certificate $MSToken = Get-MsalToken -ClientCertificate $Cert -ClientId $AppId -Authority "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -ErrorAction Stop # Set up the request headers $authHeader = @{Authorization = "Bearer $($MSToken.AccessToken)" } # 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.' [void](Invoke-RestMethod -Headers $authHeader -Uri $url -Body $body -Method POST -ContentType 'application/json' -ErrorAction Stop) Write-AuditLog "To : $To" Write-AuditLog "From : $FromAddress" Write-AuditLog "Attachments Sent : $(($Message.message.attachments).Count)" Write-AuditLog -EndFunction } catch { throw } } # End Region End } #EndRegion '.\Public\Send-TkEmailAppMessage.ps1' 294 |