Category Archives: Series: PowerShell Automation

Automating with PowerShell: Automatically following all Sharepoint Sites or Teams for all users

So a while back we had a client that uses a lot of sharepoint sites. The client only used Sharepoint online, and found it hard to find all the sites in one place. We pointed them to https://YOURDOMAIN.sharepoint.com/_layouts/15/sharepoint.aspx which gives a nice overview of sites and teams.

They came back to us saying it was a little bit of a hassle to use the overview as it only shared recently used sites, or sites that have been followed manually. Of course we wanted to help them get over this hassle so we scripted this; The following script allows you to grab all sites the user is a member of. It then adds the site to the favorites for that user. You can schedule this so each new user automatically gets added.

The Script

The script uses the Secure Application Model or just a generic Azure Application with permissions to read all sites for your tenants, and all your CSP tenants. The script finds each Team the user has joined and adds them to the favorites for that user.

$TenantID = 'CUSTOMERTENANT.ONMICROSOFT.COM'
$ApplicationId = "YOURPPLICTIONID"
$ApplicationSecret = "YOURAPPLICATIONSECRET"
 
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}

$ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/token" -Body $body -ErrorAction Stop
$headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
$Users = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users" -Headers $Headers -Method Get -ContentType "application/json").value.id
 
foreach ($userid in $users) {
    $AllTeamsURI = "https://graph.microsoft.com/beta/users/$($UserID)/JoinedTeams"
    $Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
    foreach ($Team in $teams) {
        $SiteRootReq = Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root" -Headers $Headers -Method Get -ContentType "application/json"
        $AddSitesbody = [PSCustomObject]@{
            value = [array]@{
                "id" = $SiteRootReq.id
            }
        } | convertto-json
        $FavoritedSites = Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users/$($userid)/followedSites/add" -Headers $Headers -Method POST -body $AddSitesBody -ContentType "application/json"
        write-host "Added $($SiteRootReq.webURL) to favorites for $userid"
    }


}

And that’s it! As always, Happy PowerShelling!

Automating with PowerShell: Deploying StorageSense

This is a follow up blog on last weeks blog of monitoring Storage Sense settings; to read about that check out the previous blog here. Monitoring Storage sense can ease your maintenance workload, but you do need a way to deploy StorageSense too.

You can use the script below to deploy storagesense, it even gives you the option to automatically add the OneDrive sites to the StorageSense settings, It’s important to note that StorageSense does not remove cache files that are marked as ‘Always remain offline’ – It only clears the cache for files that are untouched as long as you’ve defined.

Alright, so let’s go to deploying; as before the script uses my RunAsUser module because the StorageSense settings are user based.

The script

Using the script is straightforward; change the deployment settings to your preference, and run the script below.

#Settings
[PSCustomObject]@{
    PrefSched               = '0' #Options are: 0(Low Diskspace),1,7,30
    ClearTemporaryFiles     = $true
    ClearRecycler           = $true
    ClearDownloads          = $true
    AllowClearOneDriveCache = $true
    AddAllOneDrivelocations = $true
    ClearRecyclerDays       = '60' #Options are: 0(never),1,14,30,60
    ClearDownloadsDays      = '60' #Options are: 0(never),1,14,30,60
    ClearOneDriveCacheDays  = '60' #Options are: 0(never),1,14,30,60

    
} | ConvertTo-Json | Out-File "C:\Windows\Temp\WantedStorageSenseSettings.txt"
#

If (Get-Module -ListAvailable -Name "RunAsUser") { 
    Import-module RunAsUser
}
Else { 
    Install-PackageProvider NuGet -Force
    Set-PSRepository PSGallery -InstallationPolicy Trusted
    Install-Module RunAsUser -force -Repository PSGallery
}

$ScriptBlock = {
    $WantedSettings = Get-Content "C:\Windows\Temp\WantedStorageSenseSettings.txt" | ConvertFrom-Json
    $StorageSenseKeys = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\StorageSense\Parameters\StoragePolicy\'
    Set-ItemProperty -Path $StorageSenseKeys -name '01' -value '1' -Type DWord  -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '04' -value $WantedSettings.ClearTemporaryFiles -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '08' -value $WantedSettings.ClearRecycler -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '32' -value $WantedSettings.ClearDownloads -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '256' -value $WantedSettings.ClearRecyclerDays -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '512' -value $WantedSettings.ClearDownloadsDays -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name '2048' -value $WantedSettings.PrefSched -Type DWord -Force
    Set-ItemProperty -Path $StorageSenseKeys -name 'CloudfilePolicyConsent' -value $WantedSettings.AllowClearOneDriveCache -Type DWord -Force
    if ($WantedSettings.AddAllOneDrivelocations) {
        $CurrentUserSID = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
        $CurrentSites = Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1\ScopeIdToMountPointPathCache' -ErrorAction SilentlyContinue | Select-Object -Property * -ExcludeProperty PSPath, PsParentPath, PSChildname, PSDrive, PsProvider
        foreach ($OneDriveSite in $CurrentSites.psobject.properties.name) {
            New-Item "$($StorageSenseKeys)/OneDrive!$($CurrentUserSID)!Business1|$($OneDriveSite)" -Force
            New-ItemProperty "$($StorageSenseKeys)/OneDrive!$($CurrentUserSID)!Business1|$($OneDriveSite)" -Name '02' -Value '1' -type DWORD -Force
            New-ItemProperty "$($StorageSenseKeys)/OneDrive!$($CurrentUserSID)!Business1|$($OneDriveSite)" -Name '128' -Value $WantedSettings.ClearOneDriveCacheDays -type DWORD -Force
        }
    }

}

$null = Invoke-AsCurrentUser -ScriptBlock $ScriptBlock -UseWindowsPowerShell -NonElevatedSession

And that’s it! as always, Happy PowerShelling!

Ending the year with PowerShell: Retrospective

I always like December because everyone gets all in the mood to do these retrospectives; and 2020 was crazy for everyone! for my personally it had a heavy loss, but also a lot of positivity. I enjoy looking at the raw numbers to see how far I’ve gotten.

So, show me the numbers!

  • 1 Microsoft MVP Award (and still amazed at it!)
  • 1 MSP(that I know of) that says I’ve saved their business
  • 2 blogs a week, a total of 100 so far this year. 🙂
  • 4 MSP community and business awards.
  • 5 RMM vendors that are implementing my blogs into their products.
  • 19 webinars I joined/led and had so much fun with, Special thanks to the last ones I did this year with Huntress.
  • 15000+ people that watched these webinars.
  • 1254 e-mails from other MSPs, asking for advice, support, or just thank you messages.
  • 400+ MSPs that installed one or more of my Azure Functions
  • 7000+ upvotes on reddit posts relating to MSP stuff 🙂
  • Almost 300000 downloads of my PowerShell modules on the PowerShell Gallery this year!
  • 1.7+ million unique hits on CyberDrain.com!

So what’s next?

Well, I have some time off planned for 3 weeks so you’ll all have to miss me for a little while. I will still but blogging but not on my normal schedule of 2 blogs a week. In the coming year I’m going to be working to bring even more value to both my own MSP and others by trying to break open automation for everyone. I also have plans to create a cool educational Capture the Flag competition for Managed Service providers and/or general System Administration, next to of course my regular blogging and normal community efforts.

I’m also working with Datto to look into a vendor neutral automation/RMM user group and of course I have bigger projects like AzPAM that need some love.

I’m not done blogging by far, and I’m still super excited for what’s to come and the feedback I get from the MSP community is immense. I try to respond to all of your e-mails and messages but don’t always get the chance. I’d like to take this moment to thank you all and hope to see you again next year!

Special end of year thanks

I did the same last year and think it should become a little bit of a regular thing. For this entire year I’d like to thank my darling wife for putting up with me of course, my partners at Lime Networks and others in no particular order;

My friends at Datto: Thank you for our cooperation. I really love seeing vendors that actually love their products and that is so true on the RMM side. 🙂

My friends at Solarwinds: Mostly for the N-Central/RMM upper-management team; I had too much laughs and funs doing the MSP Masterclasses this year. Hopefully next year we can make just as much fun! I’m also going to show up on Luis’s CafĂ© Con Luis soon!

MSPGeek and MSPRU: Thanks for all community members. I love talking to you all 🙂

and a direct, and very special thanks to the following people, whom I’m able to call my friends; Jonathan Crowe, Kyle Hanslovan, Gavin Stone, Stan Lee, Maarten, Nick, Aad and of course everyone else I work closely with. 🙂

And that’s it. As always, Happy PowerShelling!

Automating with PowerShell: Adding domains to IT-Glue programmatically.

So ITGlue is a great application and has a lot of API’s available. Unfortunately there is no API to add domains or SSL certificates to IT-Glue. Seeing as I have a couple of sources where domains and SSL certificates come from I’d still like to add them programmatically.

To do this, I’ve created the small function below. It uses the Chrome cookies to login to the ITGlue webpage instead and commit a new domain. It’s quite hacky but works wonders when you need to grab data from a lot of different systems and smush them together in IT-Glue.

You could make some modifications to do this for other non-API available endpoints such as Documents, or SSL certificates.

<#
.SYNOPSIS
    Creates ITG domains using the current ITG cookie
.DESCRIPTION
    A method to create ITGlue domains by abusing the cooking and commiting the normal form workflow. This is due to no Domain API is currently available. The function expects you to be logged into ITGlue using Chrome as your browser.
.EXAMPLE
    PS C:\> New-ITGDomain -OrgID 12345 -DomainName 'example.com' -ITGURL 'Yourcompany.Itglue.com'
    Creates a new ITGlue domain example.com in organisation 12345 for the ITglue YourCompany.ITGlue.com. Please note to *not* use the API URL in this case.
.INPUTS
    OrgID = Organisation ID in IT-Glue
    DomainName = Domain name you wish to add.
    ITGURL = The normal access URL to your IT-Glue instance.
.OUTPUTS
    Fail/Success
.NOTES
   No notes
#>
function New-ITGDomain {
    param (
        [string]$OrgID,
        [string]$DomainName,
        [string]$ITGURL
    )
    $ChromeCookieviewPath = "$($ENV:TEMP)/Chromecookiesview.zip"
    if (!(Test-Path $ChromeCookieviewPath)) {
        write-host "Downloading ChromeCookieView" -ForegroundColor Green
        Invoke-WebRequest 'https://www.nirsoft.net/utils/chromecookiesview.zip' -UseBasicParsing -OutFile $ChromeCookieviewPath
        Expand-Archive -path $ChromeCookieviewPath -DestinationPath  "$($ENV:TEMP)" -Force
    }
    Start-Process -FilePath "$($ENV:TEMP)/Chromecookiesview.exe" -ArgumentList "/scomma $($ENV:TEMP)/chromecookies.csv"
    start-sleep 1
    $Cookies = import-csv "$($ENV:TEMP)/chromecookies.csv"
    write-host "Grabbing Chrome Cookies" -ForegroundColor Green
    $hosts = $cookies | Where-Object { $_.'host name' -like '*ITGlue*' }

    write-host "Found cookies. Trying to create request" -ForegroundColor Green
    write-host "Grabbing ITGlue session" -ForegroundColor Green
    $null = Invoke-RestMethod -uri "https://$($ITGURL)/$($orgid)/domains/new" -Method GET -SessionVariable WebSessionITG
    foreach ($CookieFile in $hosts) {
        $cookie = New-Object System.Net.Cookie     
        $cookie.Name = $cookiefile.Name
        $cookie.Value = $cookiefile.Value
        $cookie.Domain = $cookiefile.'Host Name'
        $WebSessionITG.Cookies.Add($cookie)
    }
    write-host "Grabbing ITGlue unique token" -ForegroundColor Green
    $AuthToken = (Invoke-RestMethod -uri "https://$($ITGURL)/$($orgid)/domains/new" -Method GET -WebSession $WebSessionITG) -match '.*csrf-token"'
    $Token = $matches.0 -split '"' | Select-Object -Index 1
    write-host "Creating domain" -ForegroundColor Green
    $Result = Invoke-RestMethod -Uri "https://$($ITGURL)/$($orgid)/domains?submit=1" -Method "POST" -ContentType "application/x-www-form-urlencoded"   -Body "utf8=%E2%9C%93&authenticity_token=$($Token)&domain%5Bname%5D=$($DomainName)&domain%5Bnotes%5D=&amp;domain%5Baccessible_option%5D=all&domain%5Bresource_access_group_ids%5D%5B%5D=&domain%5Bresource_access_accounts_user_ids%5D%5B%5D=&commit=Save" -WebSession $WebSessionITG
    if ($Result -like "*Domain has been created successfully.*") {
        write-host "Succesfully created domain $DomainName for $OrgID" -ForegroundColor Green
    }
    else {
        write-host "Failed to create $DomainName for $orgid" -ForegroundColor Red
    }
}

Automating with PowerShell: Joining teams meetings with a code

In one of the MSP communities I’m in recently the following question was asked;

Is it possible to join Microsoft Teams meeting in the same way as Webex and Zoom meetings; with just a code?

Question asked in https://msp.zone

I was actually surprised this wasn’t possible; you can join the meetings via phone with an access code but there’s no website to enter a meeting ID and just join it. I’ve decided to make this an option. For this I’ve made TeamsCodeJoiner. TeamsCodeJoiner is a Azure hosted function which allows you to create and join meetings simply by using a code.

So far; the functionality is pretty basic and more akin to a URL shortening tool, but I’m working on more functionality such as creating a code for each meeting created and letting the user known their unique access code. You can even customize the function with a custom domain; users could go to Join.yourcompanyname.com and enter the code, making joining meetings a lot easier from any device instead of having to have the URL.

Deploying the Azure Function is straight-forward, either press the Deploy with Azure button below or check the code yourself on Github and create the function by hand. In regards to costs; this will be a couple of cents a month, or a dollar if used a lot. :).

So how does this look in production? below are some screenshots;

I hope you’ll enjoy this and as always, Happy PowerShelling!

Automating with PowerShell: Backup Teams Chats

I’ve recently had a small discussion with a friend that is using Teams as his primary collaboration platform, just like our MSP does internally. He told me that the only thing that he is really missing is a backup feature of Teams chats. He often deletes entire teams or channels after a project finishes but his backup product only has the ability to backup files and folders inside of the Teams Sharepoint site.

So to help him out I’ve written the script below, the script goes over all the teams in your tenant, and backups the chat per-channel. It makes a HTML file in a reverse read order (So the top most message is the most recent one).

Permissions

As with most of my Office365 scripts; you’ll need the Secure Application Model with some added permissions. For the permissions, perform the following:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Channel” and click on “Channel.Basic.ReadAll and “ChannelMessage.Read.All”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

The Script

So its important to note that the Graph API is pretty limited when it comes to reading Teams messages; Getting channel messages are limited to 5 requests per second. In my experience this limit is even lower at times. If you’re getting rate limitation errors its best to increase the timeout a bit. 🙂

The script writes HTML files for each backup, it does not use PsWriteHTML like normally because that doesn’t like to have HTML inside of the tables, and most teams messages are HTML.

######### Secrets #########
$ApplicationId = 'YourAppID'
$ApplicationSecret = 'YourAppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantIDToBackup = 'TenantToBackup.onmicrosoft.com'
$RefreshToken = 'VeryLongRefreshToken.'
######## Secrets #########
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $TenantIDToBackup
$Header = @{
    Authorization = "Bearer $($graphToken.AccessToken)"
}
$BaseURI = "https://graph.microsoft.com/beta"
$AllMicrosoftTeams = (Invoke-RestMethod -Uri  "$($BaseURI)/groups?`$filter=resourceProvisioningOptions/Any(x:x eq 'Team')" -Headers $Header -Method Get -ContentType "application/json").value


$head = @"
<script>
function myFunction() {
    const filter = document.querySelector('#myInput').value.toUpperCase();
    const trs = document.querySelectorAll('table tr:not(.header)');
    trs.forEach(tr => tr.style.display = [...tr.children].find(td => td.innerHTML.toUpperCase().includes(filter)) ? '' : 'none');
  }</script>
<Title>LNPP - Lime Networks Partner Portal</Title>
<style>
body { background-color:#E5E4E2;
      font-family:Monospace;
      font-size:10pt; }
td, th { border:0px solid black; 
        border-collapse:collapse;
        white-space:pre; }
th { color:white;
    background-color:black; }
table, tr, td, th {
     padding: 2px; 
     margin: 0px;
     white-space:pre; }
tr:nth-child(odd) {background-color: lightgray}
table { width:95%;margin-left:5px; margin-bottom:20px; }
h2 {
font-family:Tahoma;
color:#6D7B8D;
}
.footer 
{ color:green; 
 margin-left:10px; 
 font-family:Tahoma;
 font-size:8pt;
 font-style:italic;
}
#myInput {
  background-image: url('https://www.w3schools.com/css/searchicon.png'); /* Add a search icon to input */
  background-position: 10px 12px; /* Position the search icon */
  background-repeat: no-repeat; /* Do not repeat the icon image */
  width: 50%; /* Full-width */
  font-size: 16px; /* Increase font-size */
  padding: 12px 20px 12px 40px; /* Add some padding */
  border: 1px solid #ddd; /* Add a grey border */
  margin-bottom: 12px; /* Add some space below the input */
}
</style>
"@
   

foreach ($Team in $AllMicrosoftTeams) {
    $TeamsChannels = (Invoke-RestMethod -Uri "$($BaseURI)/teams/$($Team.id)/channels" -Headers $Header -Method Get -ContentType "application/json").value
    foreach ($Channel in $TeamsChannels) {
        $i = 100

        $MessagesRaw = do {
            if (!$MessageURI) { $MessageURI = "$($BaseURI)/teams/$($Team.id)/channels/$($channel.id)/messages?`$top=100" }
            $MessagePage = (Invoke-RestMethod -Uri $MessageURI -Headers $Header -Method Get -ContentType "application/json" -ErrorAction SilentlyContinue)
            $MessageURI = $Messagepage.'@odata.nextlink'
            $Messagepage.value
            write-host "Got $i messages for $($team.displayName) / $($channel.Displayname)"
            $i = $i + 100
            start-sleep 10
        } while ($Messagepage.'@OData.nextlink')
        $MessagesHTML = $Messages | Select-Object  @{label = 'Created on'; expression = { [datetime]$_.CreatedDateTime } },
        @{label = 'From'; expression = { $_.from.user.displayname } },
        @{label = 'Message'; expression = { $_.body.content } },
        @{label = 'Message URL'; expression = { $_.body.weburl } } | ConvertTo-Html -Head $head
        [System.Web.HttpUtility]::HtmlDecode($MessagesHTML) | out-file "C:\temp\$($Team.displayName) - $($Channel.displayName).html"

    } 

     
}

So how does this look? Well; if all goes well the HTML files looks something like this;

And that’s it! as always, Happy PowerShelling!

Automating with PowerShell: Changing Modern and Basic authentication settings

A friend of mine recently asked the question on how he could edit the Modern Authentication settings in Office365. He found that when he went to the new Settings Pane for Modern Authentication he could change settings specifically to block older clients.

If you don’t know where to find this, check it out in your Office365 Portal by going to Settings -> Org Settings -> Modern Authentication;

Modern authentication settings portal 2020.

His question was how he could centrally manage these checkboxes. I explained to him that these checkboxes are actually based on something that’s existed for ages; Exchange Authentication policies. It turns out that when you change these settings a new policy is created called “No Basic Auth” and this is set as the default for the entire tenant.

So to centrally manage this it’s actually straight forward – We create our own policy called “No Basic Auth” and enable this as the default policy. From the moment this policy is deployed you’ll also be able to change these settings via the portal and they will be reflected immediately. So let’s get to the script.

This script disables all forms of basic authentication for you, so it copies the screenshot above. It’s a good plan to slowly start implementing this at all your clients – In just 6 months basic authentication is being completely deprecated with no way to turn it back on. Doing this now allows you and your clients to prepare.

The Script

######### Secrets #########
$ApplicationId = 'AppID'
$ApplicationSecret = 'AppSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'TenantID'
$RefreshToken = 'VeryLongReFreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "UPNUSedToGenerateTokens"
######### Secrets #########


$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)

$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID 
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID 

Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$CurrentConf = $customers = Get-MsolPartnerContract -All
foreach ($customer in $customers) {
    write-host " Processing $($customer.DefaultDomainName)"
    $token = New-PartnerAccessToken -ApplicationId 'a0c73c16-a7e3-4564-9a95-2bdf47383716'-RefreshToken $ExchangeRefreshToken -Scopes 'https://outlook.office365.com/.default' -Tenant $customer.TenantId
    $tokenValue = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($upn, $tokenValue)
    $customerId = $customer.DefaultDomainName
    $session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri "https://ps.outlook.com/powershell-liveid?DelegatedOrg=$($customerId)&BasicAuthToOAuthConversion=true" -Credential $credential -Authentication Basic -AllowRedirection
    $null = Import-PSSession $session -AllowClobber -CommandName "Set-AuthenticationPolicy", "Get-AuthenticationPolicy", "Get-OrganizationConfig", "New-AuthenticationPolicy", "Get-OrganizationConfig", "set-OrganizationConfig"

    $null = New-AuthenticationPolicy -Name "No Basic Auth" -AllowBasicAuthActiveSync:$FALSE -AllowBasicAuthAutodiscover:$FALSE -AllowBasicAuthImap:$false -AllowBasicAuthMapi:$FALSE  -AllowBasicAuthOfflineAddressBook:$FALSE  -AllowBasicAuthOutlookService:$FALSE  -AllowBasicAuthPop:$FALSE  -AllowBasicAuthPowershell:$FALSE  -AllowBasicAuthReportingWebServices:$FALSE  -AllowBasicAuthRpc:$FALSE -AllowBasicAuthSmtp -AllowBasicAuthWebServices:$FALSE -erroraction silentlycontinue
    $null = Set-OrganizationConfig -DefaultAuthenticationPolicy "No Basic Auth"
    Remove-PSSession $session
}

And that’s it! As always, Happy PowerShelling!

Automating with PowerShell: Creating your own password push

I was recently talking with some friends in MSPGeek about Password pushing options, and that it’s kind of strange of relying on a third party closed service to generate and send passwords to clients. You’re not 100% in control and often it misses some form of functionality like a password generator or somesuch.

As an experiment I’ve decided to create my own password pushing tool with an Azure Function. The Azure Function has the following functionality(haha)

  • You can generate passwords programmatically (/generate). You can use these passwords in other scripts for example.
  • And create a URL with either a generated password in it, or a self-typed password. (/Create)
  • You can also retrieve the password, immediately destroying it in the process (/Get)
  • And the passwords are also destroyed after the Maximum age you’ve set during set-up.

If you want a demonstration on how it works, check out https://pw.cyberdrain.com/create. If you don’t want to roll your own? feel free to use mine. 🙂

So what’s the use case?

You could also use it as validation when a user calls for something that requires a second factor. Simply create a link for them, e-mail it and await confirmation. Of course the primary reason for a tool like this is to securely share ‘first logon’ passwords.

There’s actually a lot of options; You could use it to send a license code to an end user, or you could use it to generate passwords for your application.

And that brings us to our second point; the passwords. The passwords are generated from a 10000 (English) words wordlist I’ve found online. They also get 5 random characters added to the end. Most passwords will look something like “BullfrogSymptomaticSmartphone$5%^3”.

These passwords are fairly long, still easy to type and often comply with password requirements that’s applications have.

Alright I’ve heard enough, How do I use it?

Simply click the Deploy to Azure button below. This will create the application for you. The cost will be somewhere between 50 cents and 1,50 a month, depending on how heavily you use it of course.

After deployment you can click on “Go to deployment” and “Custom Domains” to find your password push URL. This will be a little bit of an ugly URL like “azpwpushujkfh.azurewebsites.net”. You can decide to use this, or add your custom domain right away.

To add a custom domain click on the “Add Custom Domain” button and follow the instructions. You’ll have to add two DNS records to your DNS provider. One TXT record named “asuid.YOURDOMAIN.COM” with the value Microsoft gives you for validation, and a CNAME record to forward to the Azure Function.

I’d strongly suggest to also enable HTTPS-Only, and add a HTTPS certificate. You can add any pre-existing PFX file so you don’t have to buy a new one if you already have it.

So that’s it! If you have any feature requests, please drop them on the github page here. As always, Happy PowerShelling!

Automating with PowerShell: Deploying Azure Functions

First I have to make a little comment about the previous weekend; namely I’ve been awarded the Microsoft MVP status last Thursday. It’s still a surreal and bizarre experience really. I’d like to thank all my readers and my friends for supporting me. It’s great to get the acknowledgement that I’m doing the right thing for our community. So again, thanks. 🙂

Let’s get to scripting now! The biggest complaint I’ve had with my more complex stuff is that at times it’s pretty hard to implement. There are a lot of dependencies and the interfaces changes from time to time. So with the help from some super useful blogs I was able to find a way to ease implementation for a lot of my Azure Functions.

So, following are single click deployment buttons for some of my Azure Functions. I’ve also included two examples for generic functions. You can change these examples to run something on a schedule in Azure or to run something based on visiting the HTTPS page.

AzGlue

AzGlue is an API proxy for IT-Glue, Azglue was made to work around the rate-limiting and security concerns. The original blog can be found here. This script has 2 version; ‘lite’ is the version that is currently on the blog, with some minor improvements. Master is the version updated by AngusWarren which focuses on security even more.

The lite version is for IT-Glue users that want to dip their toe into making the API more secure and are worried about rate limits, it was also made available by popular request as a lot of people tend to change this version into something more suitable to their organization.

The Master version is more complex and for people that really need to limit API access and control this.

The Azure Calculation for this can be found here. The cost is around 4€ per month with 5 million API executions.

AzDynDNS

AzDynDNS is a replacement for the EOL managed DNS that Oracle used to have. This function gives you a compatible DynDNS updater for only about €2,- a month. The original blog can be found here. You’ll only need to point your NS records to Azure.

The cool thing about this one is that you can update it from any client, be it a router, PowerShell, etc, I also really like the single API key approach. 🙂

The Azure Calculation for this can be found here. The cost is around 2€ per month with 2 million DNS queries.

AzAutoMapper

So AzAutoMapper is my baby, quite popular with our team as it allows you to automatically map Microsoft Teams in the OneDrive client, without that annoying wait that exists with the registry/intune method. This is instant and thus fantastic during migrations. You’ll need the Secure Application Model configured, the original blog is here.

The Azure Calculation for this can be found here. The cost is around 1€ per month

Timer & HTTP Function

So this one is a example of how you can run script on a timer. This one only has the “main” branch as its for testing purposes. 🙂 This script runs every hour, or via a HTTPs call.

To replace the example script pulled from Github with one of your own, perform the following steps:

  • Go to the Azure Portal
  • Click the function name
  • Click on Deployment Center
  • Click on “Disconnect”
  • You can now edit the script via the Functions button -> HTTPTrigger or TimerTrigger -> Code & Test

And that’s it! As always, Happy PowerShelling!

Edit: One of my good friends suggested I’d also add a little estimated cost thingy, each blog now has a estimated cost via Azure Calculator 🙂 You can use that fairly easily to estimate the cost of your Azure Functions.

Monitoring with PowerShell: Monitoring O365 unused products

As an MSP we manage a lot of clients, and I’m pretty sure we’ve all been in situations where a client had some leavers in the company and not notify us as the administrators, or that the client had some very inactive users that don’t really need to be licensed or could be converted to a shared mailbox for example.

To help these clients we monitor the users activity and remove licenses when they are not required or start the off-boarding procedure when a client has someone has left the company. This often generates goodwill at our client and gives us the feeling of really being in control of each environment.

Another great side-effect of this monitoring script is being able to alert if users aren’t using all resources too; for example directly after a teams deployment you’ll want users to start showing Teams Activity. If they’re not, you can jump in on that and help with something like more user-training. So, lets get to scripting.

User Activity Report Monitoring

To use this script, you’ll need the Secure Application Model. You’ll also need some extra permissions on your application.

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Reports” and click on “Reports.Read.All”. Click on add permission
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.
######### Secrets #########
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret' | ConvertTo-SecureString -Force -AsPlainText
$TenantID = 'YourTenantID'
$RefreshToken = 'VeryLongRefreshToken'
$UPN = "UPN-Used-to-Generate-Tokens"
$Skiplist = "bla1.onmicrosoft.com", "bla2.onmicrosoft.com"
######### Secrets #########
$AlertingDate = (get-date).AddMonths(-2) #two months of inactivity is our alerting moment.

#######
write-host "Generating token to log into Azure AD. Grabbing all tenants" -ForegroundColor Green
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, $ApplicationSecret)
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal -Tenant $tenantID
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal -Tenant $tenantID
Connect-AzureAD -AadAccessToken $aadGraphToken.AccessToken -AccountId $upn -MsAccessToken $graphToken.AccessToken -TenantId $tenantID | Out-Null
$tenants = Get-AzureAdContract -All:$true
Disconnect-AzureAD
$ActivityReport = foreach ($Tenant in $Tenants | Where-Object {$_.DefaultDomainName -notin $Skiplist}) {
    write-host "Processing tenant $($tenant.displayname)"
    $CustGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes "https://graph.microsoft.com/.default" -ServicePrincipal -Tenant $tenant.CustomerContextId
    $Header = @{
        Authorization = "Bearer $($CustGraphToken.AccessToken)"
    }
    (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/reports/getOffice365ActiveUserDetail(period='D90')" -Headers $Header -Method get -ContentType "application/json") | ConvertFrom-Csv
}

$UserReports = foreach ($User in $ActivityReport) {
    $Onedriveused = if ($user.'Has OneDrive License' -eq $true -and $user.'OneDrive Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $ExchangeUsed = if ($user.'Has Exchange License' -eq $true -and $user.'Exchange Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $TeamsUsed = if ($user.'Has Teams License' -eq $true -and $user.'Teams Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    $SharePointUsed = if ($user.'Has SharePoint License' -eq $true -and $user.'Sharepoint Last Activity Date' -gt $AlertingDate) { $true } else { $false }
    [PSCustomObject]@{
        OneDriveUsed    = $Onedriveused
        ExchangeUsed    = $ExchangeUsed
        TeamsUsed       = $TeamsUsed
        SharePointUsed  = $SharePointUsed
        AssignedProduct = $user.'Assigned Products'
        Username        = $user.'User Principal Name'
        Displayname     = $user.'Display Name'
        IsDeleted       = $user.'Is Deleted'
        DeletedOn       = $user.'Deleted Date'
    }
}

$UserReports | Out-GridView

This gives you a little grid to check out the data, you can also edit it easily to create a monitoring component out of it for your RMM.

And that’s it! As always, Happy PowerShelling

Documenting with PowerShell: O365 Groups (And Warranty updates)

This one was request by a pal of mine that I know via a MSP discord; He wanted a way to document the users in O365 groups and see what type of group it was in one go. To do this, we’re leveraging the Graph API. As in most of my blogs I have a IT-Glue version and a version in HTML for these that use different documentation systems.

For the script you’ll also need the Secure App Model ready, if you haven’t set that up yet, check out the blog and come back to this one 🙂

ITGlue version

The IT-Glue version creates a new Flexible Asset for you, and uploads each groups into their own Flexible Asset. It creates a table of the members and owners, but also tags them. That way you can easily browse to a contact and see what groups they are in.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
  
################# IT-Glue Information ######################################
$ITGkey = "YourITGAPIKEY"
$ITGbaseURI = "https://api.eu.itglue.com"
$FlexAssetName = "Teams - Autodoc"
$Description = "Teams information automatically retrieved."
$TableStyling = "<th>", "<th style=`"background-color:#4CAF50`">"
################# /IT-Glue Information #####################################
  
  
  
write-host "Checking if IT-Glue Module is available, and if not install it." -ForegroundColor Green
#Settings IT-Glue logon information
If (Get-Module -ListAvailable -Name "ITGlueAPI") { 
    Import-module ITGlueAPI 
}
Else { 
    Install-Module ITGlueAPI -Force
    Import-Module ITGlueAPI
}

#Setting IT-Glue logon information
Add-ITGlueBaseURI -base_uri $ITGbaseURI
Add-ITGlueAPIKey $ITGkey
   
write-host "Getting IT-Glue contact list" -ForegroundColor Green
$i = 0
$AllITGlueContacts = do {
    $Contacts = (Get-ITGlueContacts -page_size 1000 -page_number $i).data.attributes
    $i++
    $Contacts
    Write-Host "Retrieved $($Contacts.count) Contacts" -ForegroundColor Yellow
}while ($Contacts.count % 1000 -eq 0 -and $Contacts.count -ne 0) 
   
write-host "Generating unique ID List" -ForegroundColor Green
 
$DomainList = foreach ($Contact in $AllITGlueContacts) {
    $ITGDomain = ($contact.'contact-emails'.value -split "@")[1]
    [PSCustomObject]@{
        Domain   = $ITGDomain
        OrgID    = $Contact.'organization-id'
        Combined = "$($ITGDomain)$($Contact.'organization-id')"
    }
} 
$DomainList = $DomainList | sort-object -Property Combined -Unique
   
write-host "Checking if Flexible Asset exists in IT-Glue." -foregroundColor green
$FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
if (!$FilterID) { 
    write-host "Does not exist, creating new." -foregroundColor green
    $NewFlexAssetData = 
    @{
        type          = 'flexible-asset-types'
        attributes    = @{
            name        = $FlexAssetName
            icon        = 'sitemap'
            description = $description
        }
        relationships = @{
            "flexible-asset-fields" = @{
                data = @(
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 1
                            name            = "Group Name"
                            kind            = "Text"
                            required        = $true
                            "show-in-list"  = $true
                            "use-for-title" = $true
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order           = 2
                            name            = "Group Settings"
                            kind            = "Textbox"
                            required        = $true
                            "show-in-list"  = $false
                            "use-for-title" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 3
                            name           = "Group Members"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 4
                            name           = "Group Owners"
                            kind           = "Textbox"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 5
                            name           = "Tagged Members"
                            kind           = "Tag"
                            "tag-type"     = "Contacts"
                            required       = $false
                            "show-in-list" = $false
                        }
                    },
                    @{
                        type       = "flexible_asset_fields"
                        attributes = @{
                            order          = 6
                            name           = "Tagged Owners"
                            kind           = "Tag"
                            "tag-type"     = "Contacts"
                            required       = $false
                            "show-in-list" = $false
                        }
                    }
                    
                )
            }
        }
    }
    New-ITGlueFlexibleAssetTypes -Data $NewFlexAssetData
    $FilterID = (Get-ITGlueFlexibleAssetTypes -filter_name $FlexAssetName).data
}
   
  
  
write-host "Creating credentials and tokens." -ForegroundColor Green
  
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
  
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
  
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    write-host "Grabbing domains for client $($Customer.name)." -ForegroundColor Green
    $CustomerDomains = Get-MsolDomain -TenantId $Customer.TenantId
    write-host "Finding possible organisation IDs" -ForegroundColor Green
    $orgid = foreach ($customerDomain in $customerdomains) {
        ($domainList | Where-Object { $_.domain -eq $customerDomain.name }).'OrgID' | Select-Object -Unique
    }
    write-host "Documenting in the following organizations." -ForegroundColor Green
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $groups = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups?&`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($group in $Groups) {
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/owners" -Headers $Headers -Method Get -ContentType "application/json").value
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value
        $TaggedMembers = foreach ($Member in $Members) {
            $email = $member.mail
            #Tagging devices
            if ($email) {
                #Write-Host "Finding all related contacts - Based on email: $email"
                (Get-ITGlueContacts -page_size "1000" -filter_primary_email $email).data
                start-sleep -miliseconds 110
            }
        }

        $TaggedOwners = foreach ($Owner in $Owners) {
            $email = $owner.mail
            #Tagging devices
            if ($email) {
             #   Write-Host "Finding all related contacts - Based on email: $email"
                (Get-ITGlueContacts -page_size "1000" -filter_primary_email $email).data
                start-sleep -miliseconds 110
            }
        }
        $GroupSettings = ($group | Select-Object @{Label = "Created on"; Expression = { $_.createdDateTime } },
            @{Label = "Created by Application"; Expression = { if (!$_.createdByAppId) { $false } else { $_.createdByAppId } } },
            @{Label = "Description"; Expression = { $_.description } },
            @{Label = "Mail Enabled"; Expression = { if ($_.Mailenabled) { "Yes" } else { "No " } } },
            @{Label = "Security Enabled"; Expression = { if ($_.SecurityEnabled) { "Yes" } else { "No " } } },
            @{Label = "Mail Nickname"; Expression = { $_.MailNickname } },
            @{Label = "E-Mail addresses"; Expression = { $_.Proxyaddress -join "," } } | convertto-html -Fragment | out-string) -replace $TableStyling

        $FlexAssetBody =
        @{
            type       = 'flexible-assets'
            attributes = @{
                traits = @{
                    'group-name'     = $group.displayName
                    'group-settings' = $GroupSettings
                    'group-members'  = ($members | Select-Object Displayname, Userprincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    "group-owners"   = ($owners | Select-Object Displayname, Userprincipalname | convertto-html -Fragment | out-string) -replace $TableStyling
                    "tagged-owners"  = $TaggedOwners.id
                    'tagged-members' = $TaggedMembers.id
                }
            }
        }
  
        write-host "   Uploading $($group.displayName) into IT-Glue" -foregroundColor green
        foreach ($org in $orgID | Select-Object -Unique) {
            $ExistingFlexAsset = (Get-ITGlueFlexibleAssets -filter_flexible_asset_type_id $FilterID.id -filter_organization_id $org).data | Where-Object { $_.attributes.traits.'group-name' -eq $group.displayName }
            #If the Asset does not exist, we edit the body to be in the form of a new asset, if not, we just upload.
            if (!$ExistingFlexAsset) {
                if ($FlexAssetBody.attributes.'organization-id') {
                    $FlexAssetBody.attributes.'organization-id' = $org
                }
                else { 
                    $FlexAssetBody.attributes.add('organization-id', $org)
                    $FlexAssetBody.attributes.add('flexible-asset-type-id', $FilterID.id)
                }
                write-output "                      Creating new Team: $($Settings.displayName) into IT-Glue organisation $org"
                New-ITGlueFlexibleAssets -data $FlexAssetBody
      
            }
            else {
                write-output "                      Updating Team: $($Settings.displayName)into IT-Glue organisation $org"
                $ExistingFlexAsset = $ExistingFlexAsset | select-object -Last 1
                Set-ITGlueFlexibleAssets -id $ExistingFlexAsset.id  -data $FlexAssetBody
            }
      
        }
    }
}

Generic HTML version

The HTML version uses PsWriteHTML to make a nice overview for you. It doesn’t have the tagging options but it’s still pretty nifty.

################### Secure Application Model Information ###################
$ApplicationId = 'YourApplicationID'
$ApplicationSecret = 'YourApplicationSecret'
$RefreshToken = 'YourVeryLongRefreshToken'
################# /Secure Application Model Information ####################
write-host "Creating credentials and tokens." -ForegroundColor Green
  
$credential = New-Object System.Management.Automation.PSCredential($ApplicationId, ($ApplicationSecret | Convertto-SecureString -AsPlainText -Force))
$aadGraphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.windows.net/.default' -ServicePrincipal
$graphToken = New-PartnerAccessToken -ApplicationId $ApplicationId -Credential $credential -RefreshToken $refreshToken -Scopes 'https://graph.microsoft.com/.default' -ServicePrincipal
  
write-host "Creating body to request Graph access for each client." -ForegroundColor Green
$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
  
write-host "Connecting to Office365 to get all tenants." -ForegroundColor Green
Connect-MsolService -AdGraphAccessToken $aadGraphToken.AccessToken -MsGraphAccessToken $graphToken.AccessToken
$customers = Get-MsolPartnerContract -All
foreach ($Customer in $Customers) {
    $ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($customer.tenantid)/oauth2/token" -Body $body -ErrorAction Stop
    $headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
    write-host "Starting documentation process for $($customer.name)." -ForegroundColor Green
    Write-Host "Grabbing all Teams" -ForegroundColor Green
    $groups = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups?&`$top=999" -Headers $Headers -Method Get -ContentType "application/json").value
    Write-Host "Grabbing all Team Settings" -ForegroundColor Green
    foreach ($group in $Groups) {
        $Owners = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/owners" -Headers $Headers -Method Get -ContentType "application/json").value
        $Members = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/groups/$($group.id)/members" -Headers $Headers -Method Get -ContentType "application/json").value

        $GroupSettings = ($group | Select-Object @{Label = "Created on"; Expression = { $_.createdDateTime } },
            @{Label = "Created by Application"; Expression = { if (!$_.createdByAppId) { $false } else { $_.createdByAppId } } },
            @{Label = "Description"; Expression = { $_.description } },
            @{Label = "Mail Enabled"; Expression = { if ($_.Mailenabled) { "Yes" } else { "No " } } },
            @{Label = "Security Enabled"; Expression = { if ($_.SecurityEnabled) { "Yes" } else { "No " } } },
            @{Label = "Mail Nickname"; Expression = { $_.MailNickname } },
            @{Label = "E-Mail addresses"; Expression = { $_.Proxyaddress -join "," } } | convertto-html -Fragment | out-string) -replace $TableStyling

     
        New-HTML {
            New-HTMLTab -Name "365 Groups: $($group.displayName)" {
                New-HTMLSection -Invisible {
                    New-HTMLSection -HeaderText 'Settings' {
                        New-HTMLTable -DataTable $GroupSettings
                    }
                }
                New-HTMLSection -Invisible {
                    New-HTMLSection -HeaderText "Members" {
                        New-HTMLTable -DataTable ($members | Select-Object Displayname, Userprincipalname)
                    }
         
                    New-HTMLSection -HeaderText "Owners" { 
                        New-HTMLTable -DataTable ($owners | Select-Object Displayname, Userprincipalname)
                    }
                }
            }
             
        } -FilePath "C:\temp\$($customer.DefaultDomainName).html" -Online
    }
}

but that’s not all! I have some other cool news that’s not really worth a blog of its own, but I’d still like to let you know.

Warranty Script updates

So a while back I created this quickly wacked together script to lookup warranties with PowerShell and create nice looking reports. The main reason for this is that there is a vendor that is showing very shady business practices around Warranty lookups and pricing them.

After a lot of requests of adding functionality or manufacturers I’ve decided to improve the script, turn it into a module and publish it to the world for easier usage. It still needs some love; especially on the help-file side and I’m hoping other community members will contribute to make this the best warranty options available. You can find the project on my Github here.

Feel free to contribute! I’d love to have other people working with me to make sure that these warranty lookup tools price themselves out of the market 😉

And that’s all! as always, Happy PowerShelling.

Automating with PowerShell: Increasing the O365 Secure Score

At the start of this week I’ve blogged about reading the secure score and documenting it. This is of course just one part of the new beta Secure Score module. The next one is actually the more fun part; applying the correct security settings to a tenant.

So first things first; The module is still rough around the edges and in beta. It’s mostly used to demonstrate how you can attack reaching a higher score, and actually helping your clients reach a higher level of security.

The SecureScore is just a baseline of added value items you can apply to each tenant. A description of each item can be found here. Don’t rely on just the secure score for your security needs.

So let’s get to increasing the secure score.

Examples

So imagine you want to apply all ‘low impact’ items, to all tenants. This is stuff like having a breakglass account, not allowing OAuth approvals by users, not having password expire policies and setting up a DLP policy that prevents sending out credit card information. You’ll run:


######### Secrets #########
$ApplicationId         = 'ApplicationID'
$ApplicationSecret     = 'AppSecret'
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "YourUPN"
######### Secrets #########
Install-module SecureScore
set-securescore -AllTenants -ControlName LowImpact -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken -Confirmed

This loops through all tenants, sets up all LowImpact items without confirmation, and poof. 🙂 But now imagine you are using external tooling for something like MFA enrollment; Microsoft doesn’t know and hasn’t given you the points for it, so lets correct that:


######### Secrets #########
$ApplicationId         = 'ApplicationID'
$ApplicationSecret     = 'AppSecret'
$TenantID              = 'YourTenantID'
$RefreshToken          = 'RefreshToken'
$ExchangeRefreshToken = 'ExchangeRefreshToken'
$UPN = "YourUPN"
######### Secrets #########
Install-Module SecureScore
set-securescore -AllTenants -ControlName MFARegistrationV2 -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken -Confirmed -ExternallyResolved

Using the parameter -ExternallyResolved you won’t apply the actual fix, and instead just tell Microsoft “Hey, we’ve solved this using another product. Please give us the points”. Pretty cool when you are using ADFS with a own MFA product, or just DUO or the likes.

But imagine you’re not sure what an item does, and what effect it has on users. You can run the following command on a single tenant to get a little explanation:

set-securescore -TenantID "Sometenant.onmicrosoft.com" -ControlName AdminMFAV2 -upn $upn -ApplicationSecret $ApplicationSecret -ApplicationId $ApplicationId -RefreshToken $RefreshToken -ExchangeToken $ExchangeRefreshToken

Result:
WARNING: This will enable multi-factor authentication for all admin users, and prompt them at first logon to configure MFA. Do you want to continue?

So, there’s a lot of stuff to play with in this module and I’ll be adding a lot of functionality in the future for other payloads. I hope you guys enjoy it and if you have any issues, let me know! 🙂

And that’s it! as always, Happy PowerShelling!

Automating with PowerShell: Impersonating users while running as SYSTEM

I’ve demonstrated in a couple of blogs like the OneDrive Sync Monitoring and the OneDrive File Monitoring that it’s possible to impersonate the current user when a script is actually started by the NT AUTHORITY\SYSTEM account.

My friends asked me if it would not be possible for other scripts to use the same approach. In the previous blogs I’ve shown that by loading the component by MurrayJu we got the ability to impersonate. I converted this into a module which you can find on https://github.com/KelvinTegelaar/RunAsUser.

This module allows you to run any script that is initiated by SYSTEM and execute it as the currently logged on user. This gives us a lot of freedom. Most RMM systems(and intune!) don’t allow monitoring under the currently logged on user. This often means that you have to work around accessing resources directly in their profile.

Some examples would be accessing installers that run in the users AppData folder, or registry items created under HKCU. Another could be scripts that require accessing shared drives or printers that are only mapped in user-space.

This is also super useful for intune scripts, because you just need to present things to the user or install things using their credentials directly.

Using the module

So, using the module is very straight forward. To install the module execute the following command:

install-module RunAsUser

After you’ve installed the module you can jump straight into scripting. There are some things to account for; The script requires SYSTEM credentials or the SeDelegateSessionUserImpersonatePrivilege privilege.

The second thing is that the output can’t be directly captured. If you want to get output from the script you’ll have to write it to a file and pick that up again in the SYSTEM session. This might sound a little confusing so I have an example below.

$scriptblock = {
$IniFiles = Get-ChildItem "$ENV:LOCALAPPDATA\Microsoft\OneDrive\settings\Business1" -Filter 'ClientPolicy*' -ErrorAction SilentlyContinue

if (!$IniFiles) {
    write-host 'No Onedrive configuration files found. Stopping script.'
    exit 1
}
 
$SyncedLibraries = foreach ($inifile in $IniFiles) {
    $IniContent = get-content $inifile.fullname -Encoding Unicode
    [PSCustomObject]@{
        'Item Count' = ($IniContent | Where-Object { $_ -like 'ItemCount*' }) -split '= ' | Select-Object -last 1
        'Site Name'  = ($IniContent | Where-Object { $_ -like 'SiteTitle*' }) -split '= ' | Select-Object -last 1
        'Site URL'   = ($IniContent | Where-Object { $_ -like 'DavUrlNamespace*' }) -split '= ' | Select-Object -last 1
    }
}
$SyncedLibraries | ConvertTo-Json | Out-File 'C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt'
}
try{
Invoke-AsCurrentUser -scriptblock $scriptblock
} catch{
write-error "Something went wrong"
}
start-sleep 2 #Sleeping 2 seconds to allow script to write to disk.
$SyncedLibraries = (get-content "C:\programdata\Microsoft OneDrive\OneDriveLibraries.txt" | convertfrom-json)
if (($SyncedLibraries.'Item count' | Measure-Object -Sum).sum -gt '280000') { 
write-host "Unhealthy - Currently syncing more than 280k files. Please investigate."
$SyncedLibraries
}
else {
write-host "Healthy - Syncing less than 280k files."
}

In the script, we’re executing the Script Block using Invoke-AsCurrentUser command. This runs that entire block of code as the currently logged on user. We then sleep for 2 seconds allowing the script block to finish writing to disk. After this finishes, we pick up the file again under the system account and process the results.

So in short; using this module opens up a lot of user-based monitoring for systems that normally only allow executing under the SYSTEM account. Hopefully this helps people solve some challenges.

As a closing remark I’d like to thank Ben Reader (@Powers_hell) for his help on the module. He assisted in cleaning up the code right after release, making it all look and feel a lot smoother and he assisted in better error handling. Thanks Buddy! 🙂

As always, Happy PowerShelling.

Automating with PowerShell: Teams Automapping

Something like 3 years ago I wrote a blog about using PowerShell to configure Onedrive sites using the odopen protocol. This was pretty much the only method to configure Onedrive to automatically map sites and have a zero-touch configuration.

Of course over the years the management side has improved and OneDrive usage has exploded. Unfortunately the onedrive automatic mapping structure isn’t where it should be yet. For example the GPO/intune method for automatic mapping configuration can take up to 8 hours to apply on any client.

During migrations and new deployment this is pretty much unacceptable. To make sure that mapping would be instant I’ve decided to create two scripts; One Azure Function which I’ve called AzMapper, and another client based script.

AzMapper requires you to create an Azure Function. To do that follow this manual and replace the script with the one below. This script is compatible with the Secure Application Model, and as such it can check all of your partner tenants too. Meaning you’ll only need to host a single version.

Replace $ApplicationID and $ApplicationSecret with your own from the Secure App Model.

You’ll also need to give your Secure Application Model a little more permissions, specifically to read the groups:

  • Go to the Azure Portal.
  • Click on Azure Active Directory, now click on “App Registrations”.
  • Find your Secure App Model application. You can search based on the ApplicationID.
  • Go to “API Permissions” and click Add a permission.
  • Choose “Microsoft Graph” and “Application permission”.
  • Search for “Sites” and click on “Sites.Read.All”. Click on add permission.
  • Search for “Team” and click on “TeamMember.Read.All”. Click on add permission.
  • Search for “Team” and click on “Team.ReadBasic.Alll”. Click on add permission.
  • Do the same for “Delegate Permissions”.
  • Finally, click on “Grant Admin Consent for Company Name.

AzMapper

using namespace System.Net
param($Request, $TriggerMetadata)
#
$ApplicationId = 'ApplicationID'
$ApplicationSecret = 'ApplicationSecret' 
#
$TenantID = $Request.Query.TenantID
$user = $Request.Query.Username

$body = @{
    'resource'      = 'https://graph.microsoft.com'
    'client_id'     = $ApplicationId
    'client_secret' = $ApplicationSecret
    'grant_type'    = "client_credentials"
    'scope'         = "openid"
}
$ClientToken = Invoke-RestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/token" -Body $body -ErrorAction Stop
$headers = @{ "Authorization" = "Bearer $($ClientToken.access_token)" }
$UserID = (Invoke-RestMethod -Uri "https://graph.microsoft.com/beta/users/$($user)" -Headers $Headers -Method Get -ContentType "application/json").id

$AllTeamsURI = "https://graph.microsoft.com/beta/users/$($UserID)/JoinedTeams"
$Teams = (Invoke-RestMethod -Uri $AllTeamsURI -Headers $Headers -Method Get -ContentType "application/json").value
$MemberOf = foreach ($Team in $Teams) {
    $SiteRootUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root"
    $SiteRootReq = Invoke-RestMethod -Uri $SiteRootUri  -Headers $Headers -Method Get -ContentType "application/json"
    $SiteDrivesUri = "https://graph.microsoft.com/beta/groups/$($Team.id)/sites/root/Lists"
    $SitesDrivesReq = (Invoke-RestMethod -Uri $SiteDrivesUri -Headers $Headers -Method Get -ContentType "application/json").value | where-object { $_.Name -eq "Shared Documents" }
    $DriveInfo = $SitesDrivesReq.ParentReference.siteid -split ','
    if($SiteRootReq.description -like "*no-auto-map*"){ continue }
    if ($null -eq [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)) { continue }
    [pscustomobject] @{
        SiteID    = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[1])}")
        WebID     = [System.Web.HttpUtility]::UrlEncode("{$($DriveInfo[2])}")
        ListID    = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.id)
        WebURL    = [System.Web.HttpUtility]::UrlEncode($SiteRootReq.webUrl)
        Webtitle  = [System.Web.HttpUtility]::UrlEncode($($Team.displayName)).Replace("+", "%20")
        listtitle = [System.Web.HttpUtility]::UrlEncode($SitesDrivesReq.name)
    }

}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $MemberOf
})

If you want sites to be skipped, you can add “no-auto-map” in the description in Teams. this will cause the script to skip that site.

Now we can browse to the AzMapper URL to check if our function is working. To test the AzMapper click on “Get Function URL” in the Azure Portal and copy the URL required. You’ll end up with something like:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==

an example of a test url would be:

https://azMapper.azurewebsites.net/api/AzOneMap?code=verylongapicodehere==&Tenantid=TENANTIDHERE&Username=USERNAMEHERE

This should return all sites for that specific user, it will contain exactly the information you need to create a odopen:// URL.

Client script

So you can schedule the client script using whatever method you prefer – as a startup script, using your RMM, or just a one-off during migrations. When a site is already configured to be synced it will skip this site. We do assume that OneDrive is already configured and just waiting to sync sites. 😉

#########################
$AutoMapURL = "https://azmapper.azurewebsites.net/api/AzOneMap"
$AutomapAPIKey = "TheAPIKeyFromTheAppAlsoKnownAsTheCode"
#########################

write-host "Grabbing OneDrive info from registry" -ForegroundColor Green
$TenantID = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").ConfiguredTenantId
$TenantDisplayName = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").Displayname
$username = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1").userEmail
$CurrentlySynced = (get-itemproperty "HKCU:\SOFTWARE\Microsoft\OneDrive\Accounts\Business1\Tenants\$($tenantdisplayname)" -ErrorAction SilentlyContinue)
Write-Host "Retrieving all possible teams."  -ForegroundColor Green
$ListOfTeams = Invoke-RestMethod -Method get -uri "$($AutoMapURL)?Tenantid=$($TenantID)&Username=$($Username)&code=$($AutomapAPIKey)" -UseBasicParsing
$Upn = [System.Web.HttpUtility]::Urldecode($username)
foreach ($Team in $ListOfTeams) {
    write-host "Checking if site is not already synced" -ForegroundColor Green
    $sitename = [System.Web.HttpUtility]::Urldecode($Team.Webtitle)
    if ($CurrentlySynced.psobject.Properties.name -like "*$($sitename) -*") {
        write-host "Site $Sitename is already being synced. Skipping." -ForegroundColor Green 
        continue
    }
    else {
        write-host "Mapping Team: $Sitename" -ForegroundColor Green
        Start-Process "odopen://sync/?siteId=$($team.SiteID)&webId=$($team.webid)&amp;listId=$($team.ListID)&userEmail=$upn&webUrl=$($team.Weburl)&webtitle=$($team.Webtitle)"
        start-sleep 5
    }
}

And that’s it! running this script will map all the sites this specific user has access to, it won’t give weird pop-ups for users that do not have access and this should help you ease all Teams deployments by a lot. As always, Happy PowerShelling!

Automating with PowerShell: Using the new Autotask REST API

So I’m a bit later than normal with blogging, that’s mostly because I was working on this project a little longer than usual. Autotask recently released update 2020.2 and this update includes a new REST API.

This is super cool, because the old API was a SOAP api and terribly inconvenient to actively use. To help people with using the new Autotask API I’ve created a module. The module is still in alpha/beta but you can download it from the PSGallery now.

The project page is here. Feel free to report any issues or do a pull request if you want to help develop the module! Just be prepared for breakage in the first couple of weeks, I’m still working on finding the best methods 🙂

 Installation instructions

The module is published to the PSGallery, download it using:

   install-module AutotaskAPI

Usage

To get items using the Autotask API you’ll first have to add the authentication headers using the` Add-AutotaskAPIAuth` function.

$Creds = get-credential    Add-AutotaskAPIAuth -ApiIntegrationcode 'ABCDEFGH00100244MMEEE333 -credentials $Creds

When the command runs, You will be asked for credentials. Using these we will try to decide the correct webservices URL for your zone based on the email address. If this fails you must manually set the webservices URL.

Add-AutotaskBaseURI -BaseURI https://webservices1.autotask.net/atservicesrest

The Base URI value has tab completion to help you find the correct one easily.

To find resources using the API, execute the Get-autotaskAPIResource function. For the Get-AutotaskAPIResource function you will need either the ID of the resource you want to retrieve, or the JSON SearchQuery you want to execute. 

Examples


To find the company with ID 12345

Get-AutotaskAPIResource -Resource Companies -ID 12345

 To get all companies that are Active:

Get-AutotaskAPIResource -Resource Companies -SearchQuery "{filter='active -eq True'}"


To create a new company, we can either make the entire JSON body ourselves, or use the New-AutotaskBody function.

$Body = New-AutotaskBody -Definitions CompanyModel 


 This creates a body for the model Company. Definitions can be tab-completed. The body will contain all expected values. If you want an empty body instead use:

  $Body = New-AutotaskBody -Definitions CompanyModel -NoContent

After setting the values for the body you want, execute: New-AutotaskAPIResource -Resource Companies -Body $body

Contributions

Feel free to send pull requests or fill out issues when you encounter any.