Search code examples
microsoft-graph-apiazure-pipelinesazure-clipowershell-core

Periodic job to find resource groups with tags corresponding to recently deleted users


We have a tag policy in place such that each resource group has a Tech-Owner-Email and Tech-Owner-Name tag.

We want to be notified when people leaving the org have resource groups under their name.

What's a good way to find groups belonging to people who have recently left?

We will want this process to run periodically and to notify us with any results (email, teams, whatever works)


Solution

  • The graph API has an endpoint that lists recently deleted users (docs)
    https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.user

    This can be polled via the Azure CLI to get the list of people, then az cli can also be used to get the list of tags from resource groups.

    The script

    #!/usr/bin/pwsh
    Write-Host "Fetching deleted users"
    $deletedUsers = az rest `
    --method "GET" `
    --url "https://graph.microsoft.com/v1.0/directory/deletedItems/microsoft.graph.user" `
    --headers "Content-Type=application/json" | ConvertFrom-Json
    
    if ($deletedUsers.value.count -eq 0)
    {
        Write-Warning "No deleted users found"
        return
    }
    
    Write-Host "Gathering subscriptions"
    $subs = az account list | ConvertFrom-Json
    $found=0
    foreach ($sub in $subs)
    {
        Write-Host "Gathering resource group tags for subscription $($sub.name)"
        $groups = az group list --subscription $sub.id | ConvertFrom-Json
        foreach ($group in $groups)
        {
            if (
                $group.tags."Tech-Owner-Email" -in $deletedUsers.value.mail -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.businessPhones -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.displayName -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.givenName -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.id -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.jobTitle -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.mail -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.mobilePhone -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.officeLocation -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.preferredLanguage -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.surname -or
                $group.tags."Tech-Owner-Name" -in $deletedUsers.value.userPrincipalName
            )
            {
                Write-Host "Found group `"$($group.name)`" belonging to `"$($group.tags."Tech-Owner-Name")`" <$($group.tags."Tech-Owner-Email")>"
                $found++
            }
        }
    }
    
    if ($found -gt 0)
    {
        throw "Found $found groups belonging to people who are no longer with us"
    }
    

    Note that $deletedUsers.value is a list of objects, but $deletedUsers.value.mail expands to a list of strings :D

    The pipeline that runs every day

    From there, you can use Azure DevOps pipelines to run this script periodically

    trigger:
      branches:
        include:
          - main
    
    schedules:
    - cron: "0 12 * * *"
      displayName: Daily check
      branches:
        include:
        - main
    
    variables:
      - group: deleted-owners-checker
    
    steps:
    - task: PowerShell@2
      displayName: az login
      inputs:
        pwsh: true
        targetType: inline
        script: az login --service-principal -u $Env:applicationId -p $Env:password --tenant $Env:tenantId
      env: 
        applicationId: $(applicationId)
        objectId: $(objectId)
        password: $(password)
        tenantId: $(tenantId)
    - task: PowerShell@2
      displayName: run script
      inputs:
        targetType: filePath
        filePath: scripts/listdeleted.ps1 # our repo has a folder called scripts containing the relevant script, update this based on your own
    - task: PowerShell@2
      displayName: az logout
      inputs:
        pwsh: true
        targetType: inline
        script: az logout
        condition: always() # log out even if other tasks fail
    

    This necessitates having a service principal that can be logged in during pipeline execution and has permissions to read your resource groups and has User.Read (Application) graph permissions. The credentials for the service principal can be passed to the pipeline using the Azure DevOps Library connected to an Azure KeyVault.

    You can alternatively use a devops ARM service connection in a CLI task instead of manually logging in to the CLI, but I've had issues with this in the past so I prefer to manually log in.

    The notification

    Note that the scripts exits with an error if any groups belonging to gone-people are found, this lets you use pipeline-failed emails as your notification system. A better method would be Teams webhooks in the PowerShell script, but that's farther than I've gotten with this.

    The infrastructure-as-code

    I prefer to automate the creation of the service principal and key vault using Terraform. This should take care of creating the key vault, creating the service principal, giving the service principal Reader perms on the resource groups (inherited), and giving the service principal the graph permission needed to query deleted users (might need admin consent).

    terraform {
      required_providers {
        azurerm = {
          source  = "hashicorp/azurerm"
          version = ">= 3.23.0"
        }
        azuread = {
          source  = "hashicorp/azuread"
          version = ">= 2.21.0"
        }
      }
    }
    
    data "azurerm_resource_group" "main" {
      name = "my-rg-where-i-want-my-key-vault-to-go" # change me!
    }
    
    data "azurerm_client_config" "main" {}
    
    resource "azuread_application" "main" {
      display_name = "deleted-owners-checker"
      owners = [
        data.azurerm_client_config.main.object_id
      ]
      required_resource_access {
        resource_app_id = "00000003-0000-0000-c000-000000000000" # microsoft graph
    
        resource_access {
          # User.Read.All
          id   = "df021288-bdef-4463-88db-98f22de89214"
          type = "Role"
        }
      }
    }
    
    resource "azuread_service_principal" "main" {
      application_id                = azuread_application.main.application_id
      owners                        = azuread_application.main.owners
    }
    
    resource "azuread_application_password" "main" {
      application_object_id = azuread_application.main.object_id
    }
    
    resource "azurerm_role_assignment" "main" {
      principal_id = azuread_service_principal.main.object_id
      scope = "/providers/Microsoft.Management/managementGroups/00000000-000000000-000000000-0000000" # management group that contains the resource groups you care about, change me!
      role_definition_name = "Reader"
    }
    
    resource "azurerm_key_vault" "main" {
      resource_group_name = data.azurerm_resource_group.main.name
      tenant_id = data.azurerm_client_config.main.tenant_id
      location = "canadacentral"
      sku_name = "standard"
      name = "deleted-owners-checker"
      lifecycle {
        ignore_changes = [
          tags
        ]
      }
    }
    
    resource "azurerm_key_vault_access_policy" "me" {
      secret_permissions      = ["Get", "List", "Set", "Delete", "Purge"]
      certificate_permissions = ["Get", "List", "Create", "Delete", "Import", "Purge"]
      key_permissions         = ["Get", "List", "Create", "Delete", "Import", "Purge"]
      key_vault_id            = azurerm_key_vault.main.id
      tenant_id               = azurerm_key_vault.main.tenant_id
      object_id               = data.azurerm_client_config.main.object_id
    }
    
    resource "azurerm_key_vault_access_policy" "devops" {
      secret_permissions      = ["Get", "List"]
      key_vault_id            = azurerm_key_vault.main.id
      tenant_id               = azurerm_key_vault.main.tenant_id
      object_id               = "0000-0000-0000-0000"
      # You would think this would be the object ID of the principal used by the service connection used when connecting the key vault to the Azure DevOps Library
      # In reality, you're better off creating the key vault access policy from the Azure DevOps web interface, since the object ID was different for us when we tried
      # The azure pipeline should let you know if the object ID is wrong. We got a pipeline error like this when we first tried. The error should give you the correct object ID to use
    # objectId: "The user, group or application 'appid=***;oid=5555-55555-5555-5555;iss=https://sts.windows.net/zzzz-zzzz-zzzz-zzzz/' does not have secrets get permission on key vault 'deleted-owners-checker;location=canadacentral'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287. The specified Azure service connection needs to have Get, List secret management permissions on the selected key vault. To set these permissions, download the ProvisionKeyVaultPermissions.ps1 script from build/release logs and execute it, or set them from the Azure portal."
    # applicationId: "The user, group or application 'appid=***;oid=5555-55555-5555-5555;iss=https://sts.windows.net/zzzz-zzzz-zzzz-zzzz/' does not have secrets get permission on key vault 'deleted-owners-checker;location=canadacentral'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287. The specified Azure service connection needs to have Get, List secret management permissions on the selected key vault. To set these permissions, download the ProvisionKeyVaultPermissions.ps1 script from build/release logs and execute it, or set them from the Azure portal."
    # tenantId: "The user, group or application 'appid=***;oid=5555-55555-5555-5555;iss=https://sts.windows.net/zzzz-zzzz-zzzz-zzzz/' does not have secrets get permission on key vault 'deleted-owners-checker;location=canadacentral'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287. The specified Azure service connection needs to have Get, List secret management permissions on the selected key vault. To set these permissions, download the ProvisionKeyVaultPermissions.ps1 script from build/release logs and execute it, or set them from the Azure portal."
    # password: "The user, group or application 'appid=***;oid=5555-55555-5555-5555;iss=https://sts.windows.net/zzzz-zzzz-zzzz-zzzz/' does not have secrets get permission on key vault 'deleted-owners-checker;location=canadacentral'. For help resolving this issue, please see https://go.microsoft.com/fwlink/?linkid=2125287. The specified Azure service connection needs to have Get, List secret management permissions on the selected key vault. To set these permissions, download the ProvisionKeyVaultPermissions.ps1 script from build/release logs and execute it, or set them from the Azure portal."
    
    }
    
    locals {
      kv_secrets = {
        objectId = azuread_application.main.object_id
        tenantId = azuread_service_principal.main.application_tenant_id
        applicationId = azuread_service_principal.main.application_id
        password = azuread_application_password.main.value
      }
    }
    
    resource "azurerm_key_vault_secret" "main" {
      for_each = local.kv_secrets
      name = each.key
      key_vault_id = azurerm_key_vault.main.id
      value = each.value
      depends_on = [
        azurerm_key_vault_access_policy.me
      ]
    }