From 5a9bdaaca91707cda5d1ca51e46e3181f4209874 Mon Sep 17 00:00:00 2001 From: Matt Boersma Date: Wed, 14 Aug 2024 15:35:25 +0000 Subject: [PATCH] Add --role-assignments flag to clean unattached RAs --- .vscode/launch.json | 20 ++++ Dockerfile | 8 +- Makefile | 4 +- README.md | 27 +++++ main.go | 34 ++++-- rg-cleanup.sh | 35 ++++++ templates/rg-cleaner-logic-app-uami.bicep | 126 ++++++++++++++++++---- 7 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 .vscode/launch.json create mode 100755 rg-cleanup.sh diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bce8f8f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch file", + "type": "go", + "request": "launch", + "mode": "debug", + "program": "main.go", + "args": ["--ttl", "4h", "--az-cli", "--dry-run",], + "env": { + "SUBSCRIPTION_ID": "xxxxx-xxxx-xxxx-xxxx-xxxxx", + }, + } + + ] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a984b73..dd95d65 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ -FROM alpine:3.18 -COPY bin/rg-cleanup /usr/local/bin -ENTRYPOINT [ "rg-cleanup" ] +FROM mcr.microsoft.com/cbl-mariner/base/core:2.0 +RUN tdnf install -y azure-cli jq && tdnf clean all +COPY rg-cleanup.sh /usr/local/bin +COPY bin/rg-cleanup ./bin +ENTRYPOINT [ "rg-cleanup.sh" ] diff --git a/Makefile b/Makefile index d05b0e6..08ba1f8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -IMAGE_REGISTRY ?= k8sprow.azurecr.io +IMAGE_REGISTRY ?= k8sprowcomm.azurecr.io IMAGE_NAME := rg-cleanup -IMAGE_VERSION ?= v0.2.0 +IMAGE_VERSION ?= v0.4.6 .PHONY: all all: build diff --git a/README.md b/README.md index daefe0a..075f769 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,30 @@ az deployment group create -g "" -f ./templates/rg-cleaner-logic-app-ua regex="" # Optional # Other optional parameters are available, please refer to the deployment bicep file. ``` + +### Orphaned Role Assignment cleanup + +The tool also provides a way to clean up orphaned role assignments. This is useful when you have role assignments that are no longer associated with any resource groups. To enable this feature, you can use the `--role-assignment` flag when running the tool. + +This relies on a correlated query with the Microsoft Graph API, the permissions for which can be granted with a script like this: + +```powershell +Connect-AzureAD + +$GraphAppId = "00000003-0000-0000-c000-000000000000" # Don't change this value +$NameOfMSI = "rg-cleanup-og" +$Permissions = @( + "Application.Read.All", + "Directory.Read.All", + "User.Read.All", +) + +$MSI = (Get-AzureADServicePrincipal -Filter "displayName eq '$NameOfMSI'") +Start-Sleep -Seconds 10 +$GraphServicePrincipal = Get-AzureADServicePrincipal -Filter "appId eq '$GraphAppId'" + +foreach ($PermissionName in $Permissions) { + $AppRole = $GraphServicePrincipal.AppRoles | Where-Object { $_.Value -eq $PermissionName -and $_.AllowedMemberTypes -contains "Application" } + New-AzureAdServiceAppRoleAssignment -ObjectId $MSI.ObjectId -PrincipalId $MSI.ObjectId -ResourceId $GraphServicePrincipal.ObjectId -Id $AppRole.Id +} +``` diff --git a/main.go b/main.go index 157883a..480a225 100644 --- a/main.go +++ b/main.go @@ -48,15 +48,19 @@ type options struct { ttl time.Duration identity bool regex string + cli bool } func (o *options) validate() error { - if o.clientID == "" { - return fmt.Errorf("$%s is empty", aadClientIDEnvVar) - } if o.subscriptionID == "" { return fmt.Errorf("$%s is empty", subscriptionIDEnvVar) } + if o.cli { + return nil + } + if o.clientID == "" { + return fmt.Errorf("$%s is empty", aadClientIDEnvVar) + } if o.identity { return nil } @@ -77,6 +81,7 @@ func defineOptions() *options { o.subscriptionID = os.Getenv(subscriptionIDEnvVar) flag.BoolVar(&o.dryRun, "dry-run", false, "Set to true if we should run the cleanup tool without deleting the resource groups.") flag.BoolVar(&o.identity, "identity", false, "Set to true if we should user-assigned identity for AUTH") + flag.BoolVar(&o.cli, "az-cli", false, "Set to true if we should use az cli for AUTH") flag.DurationVar(&o.ttl, "ttl", defaultTTL, "The duration we allow resource groups to live before we consider them to be stale.") flag.StringVar(&o.regex, "regex", defaultRegex, "Only delete resource groups matching regex") flag.Parse() @@ -85,6 +90,7 @@ func defineOptions() *options { func main() { log.Println("Initializing rg-cleanup") + log.Printf("args: %v\n", os.Args) o := defineOptions() if err := o.validate(); err != nil { @@ -96,7 +102,7 @@ func main() { log.Println("Dry-run enabled - printing logs but not actually deleting resource groups") } - r, err := getResourceGroupClient(o.clientID, o.clientSecret, o.tenantID, o.subscriptionID, o.identity) + r, err := getResourceGroupClient(*o) if err != nil { log.Printf("Error when obtaining resource group client: %v", err) panic(err) @@ -193,34 +199,42 @@ func regexMatchesResourceGroupName(regex string, rgName string) (bool, error) { return false, nil } -func getResourceGroupClient(clientID, clientSecret, tenantID, subscriptionID string, identity bool) (*armresources.ResourceGroupsClient, error) { +func getResourceGroupClient(o options) (*armresources.ResourceGroupsClient, error) { options := arm.ClientOptions{ ClientOptions: azcore.ClientOptions{ Cloud: cloud.AzurePublic, }, } possibleTokens := []azcore.TokenCredential{} - if identity { + if o.identity { micOptions := azidentity.ManagedIdentityCredentialOptions{ - ID: azidentity.ClientID(clientID), + ID: azidentity.ClientID(o.clientID), } miCred, err := azidentity.NewManagedIdentityCredential(&micOptions) if err != nil { return nil, err } possibleTokens = append(possibleTokens, miCred) - } else { - spCred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret, nil) + } else if o.clientSecret != "" { + spCred, err := azidentity.NewClientSecretCredential(o.tenantID, o.clientID, o.clientSecret, nil) if err != nil { return nil, err } possibleTokens = append(possibleTokens, spCred) + } else if o.cli { + cliCred, err := azidentity.NewAzureCLICredential(nil) + if err != nil { + return nil, err + } + possibleTokens = append(possibleTokens, cliCred) + } else { + log.Println("unknown login option. login may not succeed") } chain, err := azidentity.NewChainedTokenCredential(possibleTokens, nil) if err != nil { return nil, err } - resourceGroupClient, err := armresources.NewResourceGroupsClient(subscriptionID, chain, &options) + resourceGroupClient, err := armresources.NewResourceGroupsClient(o.subscriptionID, chain, &options) if err != nil { return nil, err } diff --git a/rg-cleanup.sh b/rg-cleanup.sh new file mode 100755 index 0000000..aed6dd0 --- /dev/null +++ b/rg-cleanup.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euo pipefail + +# Parse out --role-assignments flag +role_assignments_flag=false +while [[ "$#" -gt 0 ]]; do + case $1 in + --role-assignments) role_assignments_flag=true ;; + *) args+=("$1") ;; # Store other arguments in an array + esac + shift +done + +# Call resource group cleanup binary with its command-line arguments +./bin/rg-cleanup "${args[@]}" + +# Exit if not cleaning up unattached role assignments +if [ "$role_assignments_flag" != true ]; then + echo "Skipping unattached role assignment cleanup..." + exit 0 +fi + +# Clean up unattached role assignments +az login --identity +az account set --subscription "${SUBSCRIPTION_ID}" + +assignments=$(az role assignment list --scope "/subscriptions/$SUBSCRIPTION_ID" -o tsv --query "[?principalName==''].id") +if [ -z "$assignments" ]; then + echo "No unattached role assignments found." + exit 0 +fi +echo "Deleting unattached role assignments:" +echo "$assignments" +xargs az role assignment delete --ids <<< "$assignments" diff --git a/templates/rg-cleaner-logic-app-uami.bicep b/templates/rg-cleaner-logic-app-uami.bicep index ba941d0..5bec522 100644 --- a/templates/rg-cleaner-logic-app-uami.bicep +++ b/templates/rg-cleaner-logic-app-uami.bicep @@ -8,12 +8,13 @@ param aci string = 'aci' param dryrun bool = true param ttl string = '' param regex string = '' +param role_assignments bool = true param csubscription string = subscription().subscriptionId param location string = resourceGroup().location param rg_name string = resourceGroup().name var default_container_cmd = [ - 'rg-cleanup' + 'rg-cleanup.sh' '--identity' ] var dryrun_cmd = [ @@ -27,9 +28,12 @@ var regex_cmd = [ '--regex' regex ] +var role_assignments_cmd = [ + '--role-assignments' +] var add_regex_cmd = concat(default_container_cmd, empty(regex) ? [] : regex_cmd) var add_ttl_cmd = concat(add_regex_cmd, empty(ttl) ? [] : ttl_cmd) -var container_command = concat(add_ttl_cmd, dryrun ? dryrun_cmd : []) +var container_command = concat(add_ttl_cmd, dryrun ? dryrun_cmd : [], role_assignments ? role_assignments_cmd : []) var encoded_sub = uriComponent(csubscription) var encoded_rg = uriComponent(rg_name) @@ -43,7 +47,7 @@ var cg_id = '${rg_id}/providers/Microsoft.ContainerInstance/containerGroups/${en var cn_id = '${cg_id}/containers/${encoded_cn}' var aci_api_id = '/subscriptions/${csubscription}/providers/Microsoft.Web/locations/${location}/managedApis/aci' -resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = { +resource logic_app 'Microsoft.Logic/workflows@2019-05-01' = { name: logic_app_name location: location identity: { @@ -136,20 +140,6 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = { } } } - Delay: { - runAfter: { - Create_or_update_a_container_group: [ - 'Succeeded' - ] - } - type: 'Wait' - inputs: { - interval: { - count: 1 - unit: 'Minute' - } - } - } Delete_a_container_group: { runAfter: { Get_logs_from_a_container_instance: [ @@ -175,7 +165,7 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = { } Get_logs_from_a_container_instance: { runAfter: { - Delay: [ + Until: [ 'Succeeded' ] } @@ -193,6 +183,106 @@ resource logic_app 'Microsoft.Logic/workflows@2017-07-01' = { } } } + Get_properties_of_a_container_group: { + runAfter: { + Create_or_update_a_container_group: [ + 'Succeeded' + ] + } + type: 'ApiConnection' + inputs: { + host: { + connection: { + name: '@parameters(\'$connections\')[\'aci\'][\'connectionId\']' + } + } + method: 'get' + path: cg_id + queries: { + 'x-ms-api-version': '2019-12-01' + } + } + } + Initialize_variable: { + runAfter: { + Get_properties_of_a_container_group: [ + 'Succeeded' + ] + } + type: 'InitializeVariable' + inputs: { + variables: [ + { + name: 'complete' + type: 'string' + value: '@body(\'Get_properties_of_a_container_group\')?[\'properties\']?[\'instanceView\']?[\'state\']' + } + ] + } + } + Until: { + actions: { + Get_properties_of_a_container_group_loop: { + type: 'ApiConnection' + inputs: { + host: { + connection: { + name: '@parameters(\'$connections\')[\'aci\'][\'connectionId\']' + } + } + method: 'get' + path: cg_id + queries: { + 'x-ms-api-version': '2019-12-01' + } + } + } + // this works because there is only one container in the group, otherwise it might overwrite the variable + For_each: { + foreach: '@body(\'Get_properties_of_a_container_group_loop\')[\'properties\'][\'containers\']' + actions: { + Set_variable: { + type: 'SetVariable' + inputs: { + name: 'complete' + value: '@items(\'For_each\')?[\'properties\']?[\'instanceView\']?[\'currentState\']?[\'state\']' + } + } + } + runAfter: { + Get_properties_of_a_container_group_loop: [ + 'Succeeded' + ] + } + type: 'Foreach' + } + Delay: { + runAfter: { + For_each: [ + 'Succeeded' + ] + } + type: 'Wait' + inputs: { + interval: { + count: 1 + unit: 'Minute' + } + } + } + } + runAfter: { + Initialize_variable: [ + 'Succeeded' + ] + } + expression: '@equals(variables(\'complete\'),\'Terminated\')' + limit: { + count: 60 + timeout: 'PT1H' + } + type: 'Until' + } } outputs: { }