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)&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!

24 Comments

  1. Mike July 9, 2020 at 3:42 pm

    Hi Kelvin, been eagerly awaiting this one 🙂
    Im getting an “access denied” message when running the function – it looks like its occurring in the MemberOf for each.
    The invoke-restmethods inside the foreach all come back with the message, however I know my ID and Secret are valid, as it is successfully getting the list of Teams the user is a member of?
    Looks like its failing in our own tenant as well as our customers.

    Really appreciate your articles & your amazing contribution to the community!

    1. Kelvin Tegelaar July 9, 2020 at 4:13 pm

      Hi Mike,

      I just realized that I forgot to add you’ll need to add some groups to your Secure Application Model App. I’ll update the post in the coming 5 minutes. 🙂

      1. Mike July 9, 2020 at 4:29 pm

        Bosch, easy as that 🙂

        That’s done the trick, many thanks for your super quick response! Amazing work! 🙂

  2. Bryan Schulz July 10, 2020 at 4:49 pm

    Hello,

    I am attempting to get this to work in our AZ enviroment.

    I am stuck on the part where I need to give my Secure Application Model a little more permissions.

    I cannot find the function I added in the list to allow the permissions.

    Could you give any tips or tricks to get it to show?

    Thank you

    1. Kelvin Tegelaar July 10, 2020 at 5:46 pm

      You don’t need to look for the function, but the Application ID (GUID) you use for the Secure Application Model. You can also search for the display name. Hope that helps! 🙂

      1. Bryan Schulz July 20, 2020 at 2:44 pm

        Awesome i have one more issue.

        After that we re-ran the script and got the following error:
        The term ‘enter path or command to execute e.g.: C:\Users\bschulz\Overview Technology Solutions Inc\Coding – General\AZGlue/src/foo.ps1 or Invoke-Pester’ is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.
        + CategoryInfo : ObjectNotFound: (enter path or c…r Invoke-Pester:String) [], ParentContainsErrorRecordException
        + FullyQualifiedErrorId : CommandNotFoundException

        Cannot get past step 5 in the README.txt

        1. Kelvin Tegelaar July 20, 2020 at 5:59 pm

          Hi Bryan,

          AzGlue has nothing to do with the AzAutomapper. These are two different projects, maybe you copied the wrong source?

  3. Darren McCabe July 24, 2020 at 4:00 pm

    What a legend, I only recently started using Intune and Autopilot and the whole “8hour wait” thing was murdering me.

    I will admit to not having a clue what I have just done, but I managed to follow through it and get it working first time.

    THank you so much

  4. Darren McCabe July 24, 2020 at 4:36 pm

    Hi Kelvin,

    So as an addendum to my previous statement, we are wanting to run this as part of our InTune/Autopilot deployments.

    When I add it as a script to Intune I have no control over when it runs. It appears that it is running before OneDrive has been configured by InTune, therefore it isnt actually working, but InTune marks the deployment as successful.

    Is there anyway to make it wait until OneDrive is done before it runs the script?

  5. Pingback: Automating with PowerShell: Deploying Azure Functions - CyberDrain

  6. David Rigdon October 13, 2020 at 6:45 pm

    Hey Kelvin!

    I’m got a slight issue that I’m not sure where to start troubleshooting… The script runs great on the client side, it identifies the. proper teams that the user is apart of, and then even says “Mapping Team: TeamName. But it never shows up in OneDrive. Thoughts?

      1. David Rigdon October 13, 2020 at 11:21 pm

        Yep that worked!

  7. ictgorilla October 19, 2020 at 8:18 pm

    Kevin,
    Love to get this working..

    where can i find
    $AutomapAPIKey = “TheAPIKeyFromTheAppAlsoKnownAsTheCode”

    🙂

  8. Stephen Moody October 20, 2020 at 7:37 am

    I am having trouble getting the no-auto-map to work. For all Teams, the $SiteRootReq.description appears to be pulling $null, even when it does have a value. My secure app model app has Team.ReadBasic.All permissions (both delegated and app). Any idea what I might be missing?

    1. Stephen Moody October 23, 2020 at 6:49 pm

      Figured this out. At least in my tenant, the groups/site-id/sites/root endpoint doesn’t consistently return a description. Not sure if that’s an API bug or a permissions issue, but changing line 29 to check $Team.description instead did the trick.

  9. Chris December 1, 2020 at 12:08 am

    Looking at using this script, every time I run it I’m getting 403 forbidden. I’m using an App Registration with API permissions delegated as specified, copying the secret to authenticate. I’ve followed the instructions and can’t figure out where I’ve gone wrong. I’m using the latest version of your GitHub code.

    Errors from my code:

    2020-11-30T23:00:06.689 [Error] ERROR: Response status code does not indicate success: 403 (Forbidden).Exception :Type : Microsoft.PowerShell.Commands.HttpResponseExceptionResponse : StatusCode: 403, ReasonPhrase: ‘Forbidden’, Version: 1.1, Content: System.Net.Http.HttpConnectionResponseContent,

    Response status code does not indicate success: 403 (Forbidden).Source :
    Microsoft.PowerShell.Commands.InvokeRestMethodCommandErrorDetails : {“error”: {“code”: “Authorization_RequestDenied”,”message”: “Insufficient privileges to complete the operation}

    Any help/guidance would be much appreciated, thanks!

  10. Stephen Moody January 7, 2021 at 9:30 pm

    This has been working great for us!

    I’m looking into how to extend this for Private channels since we have some clients starting to use them. Each private channel creates its own site but I can’t figure out how discover the associated siteid/webid/listid. Not sure there’s a way to get this with Graph API yet… any ideas?

    1. Joel Charters March 11, 2021 at 10:37 pm

      Any progress figuring out how to apply it to private channels yet? Would be interested if you had success. Cheers!

    2. Aaron Osmer April 2, 2021 at 3:26 pm

      Also interested in adding this feature!

  11. Chien May 5, 2021 at 12:35 am

    This seems to only work on sites where the user has write access. Is there any way to extend that to sites where users have Read-Only access?

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.