diff --git a/.claude/skills/remediation-tool/references/gotchas.md b/.claude/skills/remediation-tool/references/gotchas.md index b937fce..0ceed22 100644 --- a/.claude/skills/remediation-tool/references/gotchas.md +++ b/.claude/skills/remediation-tool/references/gotchas.md @@ -1,5 +1,17 @@ # Gotchas — Permissions, Roles, Consent +## Onboarding a new tenant + +Run `onboard-tenant.sh` after admin consents each app. This assigns required directory roles automatically. + +Quick steps: +1. Send consent URLs (Tenant Admin FIRST, then others) +2. After admin accepts: `bash scripts/onboard-tenant.sh ` +3. Verify output shows all roles [OK] +4. Update tenant table below + +If Tenant Admin is not yet consented, onboard-tenant.sh will output all needed consent URLs. + ## App Suite (tiered architecture) Five multi-tenant apps replace the old single over-permissioned app. Use minimum necessary tier. @@ -101,13 +113,14 @@ If token request or API call returns AADSTS650052 referencing `WindowsDefenderAT ## Tenants where apps are consented (as of 2026-04-20) -| Tenant | Tenant ID | Security Investigator | Exchange Operator | User Manager | Tenant Admin | Defender | Directory roles | Notes | -|---|---|---|---|---|---|---|---|---| -| Valleywide Plastering | 5c53ae9f... | old app only | — | — | — | — | User Admin (old app) | Needs migration to new app suite | -| Dataforth | 7dfa3ce8... | old app only | — | — | — | — | User Admin + Exchange Admin (old app) | Needs migration | -| Cascades Tucson | 207fa277-e9d8-4eb7-ada1-1064d2221498 | old app only | — | — | — | — | User Admin + Exchange Admin (old app) | IdentityRiskyUser scope still not consented as of 2026-04-16 | -| Grabblaw | 032b383e-96e4-491b-880d-3fd3295672c3 | YES (2026-04-20) | — | YES (2026-04-20) | — | — | none | No directory roles assigned yet | +| Tenant | Tenant ID | Sec Inv | Exch Op | User Mgr | Tenant Admin | Defender | Exch Admin (Sec Inv) | User Admin (User Mgr) | Auth Admin (User Mgr) | Notes | +|---|---|---|---|---|---|---|---|---|---|---| +| Valleywide Plastering | 5c53ae9f... | old app only | — | — | — | — | — | old app only | — | Needs migration to new app suite | +| Dataforth | 7dfa3ce8... | old app only | — | — | — | — | old app only | old app only | — | Needs migration | +| Cascades Tucson | 207fa277-e9d8-4eb7-ada1-1064d2221498 | old app only | — | — | — | — | old app only | old app only | — | IdentityRiskyUser scope still not consented as of 2026-04-16 | +| Grabblaw | 032b383e-96e4-491b-880d-3fd3295672c3 | YES (2026-04-20) | — | YES (2026-04-20) | — | — | NOT ASSIGNED | NOT ASSIGNED | NOT ASSIGNED | Run onboard-tenant.sh | +| martylryan.com | (resolve via script) | — | — | YES (old app) | — | — | — | NOT ASSIGNED | NOT ASSIGNED | New SP not yet onboarded — run onboard-tenant.sh after Tenant Admin consent | **Migration note:** Valleywide, Dataforth, and Cascades still use the old deprecated app. Next visit: consent Security Investigator + assign Exchange Administrator role to new SP, then retire old app consent. -Keep this table updated when rolling out to new tenants or migrating existing ones. +Keep this table updated when rolling out to new tenants or migrating existing ones. Run `onboard-tenant.sh` after each consent and update the role columns from the script's final status output. diff --git a/.claude/skills/remediation-tool/scripts/get-token.sh b/.claude/skills/remediation-tool/scripts/get-token.sh index 55aea61..ed22f93 100644 --- a/.claude/skills/remediation-tool/scripts/get-token.sh +++ b/.claude/skills/remediation-tool/scripts/get-token.sh @@ -52,6 +52,13 @@ case "$TIER" in VAULT_PATH="msp-tools/computerguru-tenant-admin.sops.yaml" SCOPE_URL="https://graph.microsoft.com/.default" ;; + tenant-admin-onboard) + # Same app as tenant-admin; this alias signals the token is being used + # during initial tenant onboarding (clearer error messages on failure). + CLIENT_ID="709e6eed-0711-4875-9c44-2d3518c47063" + VAULT_PATH="msp-tools/computerguru-tenant-admin.sops.yaml" + SCOPE_URL="https://graph.microsoft.com/.default" + ;; defender) CLIENT_ID="dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b" VAULT_PATH="msp-tools/computerguru-defender-addon.sops.yaml" @@ -59,7 +66,7 @@ case "$TIER" in ;; *) echo "ERROR: unknown tier '$TIER'." >&2 - echo "Valid tiers: investigator | investigator-exo | exchange-op | user-manager | tenant-admin | defender" >&2 + echo "Valid tiers: investigator | investigator-exo | exchange-op | user-manager | tenant-admin | tenant-admin-onboard | defender" >&2 exit 2 ;; esac @@ -130,6 +137,24 @@ RESP=$(curl -s --max-time 15 -X POST \ TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') if [[ -z "$TOKEN" ]]; then + ERROR_CODE=$(echo "$RESP" | jq -r '.error_codes[0] // empty' 2>/dev/null || true) + ERROR_DESC=$(echo "$RESP" | jq -r '.error_description // empty' 2>/dev/null || true) + + # AADSTS7000229 — service principal not found in tenant (not consented) + if echo "$ERROR_DESC" | grep -qi "7000229\|AADSTS7000229" || [[ "$ERROR_CODE" == "7000229" ]]; then + echo "ERROR: AADSTS7000229 — app not consented in tenant $TENANT_ID (tier=$TIER)" >&2 + echo "" >&2 + echo " The '${TIER}' service principal has not been authorized in this tenant." >&2 + echo " Send this consent URL to the customer Global Admin:" >&2 + echo "" >&2 + echo " https://login.microsoftonline.com/${TENANT_ID}/adminconsent?client_id=${CLIENT_ID}&redirect_uri=https://azcomputerguru.com&prompt=consent" >&2 + echo "" >&2 + echo " After the admin accepts, run onboard-tenant.sh to assign required directory roles:" >&2 + SCRIPT_DIR_ERR="$(dirname "${BASH_SOURCE[0]}")" + echo " bash ${SCRIPT_DIR_ERR}/onboard-tenant.sh ${TARGET}" >&2 + exit 5 + fi + echo "ERROR: token request failed (tenant=$TENANT_ID tier=$TIER)" >&2 echo "$RESP" >&2 exit 5 diff --git a/.claude/skills/remediation-tool/scripts/onboard-tenant.sh b/.claude/skills/remediation-tool/scripts/onboard-tenant.sh new file mode 100644 index 0000000..2453804 --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/onboard-tenant.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# Assign required Entra directory roles to ComputerGuru MSP service principals +# in a newly-consented customer tenant. +# +# Usage: onboard-tenant.sh [--dry-run] +# +# What this script does: +# 1. Resolves the tenant ID +# 2. Acquires a Tenant Admin token (fails gracefully if not consented) +# 3. For each SP that needs directory roles, checks current assignments +# 4. Assigns any missing roles +# 5. Prints a final status table +# +# Exit codes: +# 0 all roles present or successfully assigned +# 1 resolve failure +# 2 Tenant Admin not consented (consent URLs printed) +# 3 vault error +# 10 partial failure (some roles could not be assigned) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +TARGET="${1:?Usage: onboard-tenant.sh [--dry-run]}" +DRY_RUN=false +[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true + +# ── App IDs ─────────────────────────────────────────────────────────────────── +APP_SEC_INV="bfbc12a4-f0dd-4e12-b06d-997e7271e10c" +APP_EXCH_OP="b43e7342-5b4b-492f-890f-bb5a4f7f40e9" +APP_USER_MGR="64fac46b-8b44-41ad-93ee-7da03927576c" +APP_TENANT_ADMIN="709e6eed-0711-4875-9c44-2d3518c47063" +APP_DEFENDER="dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b" + +# ── Role definition GUIDs ───────────────────────────────────────────────────── +ROLE_EXCHANGE_ADMIN="29232cdf-9323-42fd-ade2-1d097af3e4de" +ROLE_USER_ADMIN="fe930be7-5e62-47db-91af-98c3a49a38b1" +ROLE_AUTH_ADMIN="c4e39bd9-1100-46d3-8c65-fb160da0071f" + +CONSENT_BASE="https://login.microsoftonline.com" +CONSENT_REDIRECT="https://azcomputerguru.com" + +# ── Helper: print consent URLs for all apps ─────────────────────────────────── +print_consent_urls() { + local tenant_id="$1" + echo "" + echo "[INFO] Consent URLs for tenant $tenant_id (provide to customer Global Admin):" + echo " [1] Tenant Admin (consent FIRST — needed for role assignment):" + echo " ${CONSENT_BASE}/${tenant_id}/adminconsent?client_id=${APP_TENANT_ADMIN}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + echo "" + echo " [2] Security Investigator:" + echo " ${CONSENT_BASE}/${tenant_id}/adminconsent?client_id=${APP_SEC_INV}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + echo "" + echo " [3] Exchange Operator:" + echo " ${CONSENT_BASE}/${tenant_id}/adminconsent?client_id=${APP_EXCH_OP}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + echo "" + echo " [4] User Manager:" + echo " ${CONSENT_BASE}/${tenant_id}/adminconsent?client_id=${APP_USER_MGR}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + echo "" + echo " [5] Defender Add-on (MDE-licensed tenants only):" + echo " ${CONSENT_BASE}/${tenant_id}/adminconsent?client_id=${APP_DEFENDER}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + echo "" + echo " Entra role assignment URL (for manual verification after script runs):" + echo " https://entra.microsoft.com/#@${tenant_id}/view/Microsoft_AAD_IAM/RolesAndAdministratorsMenuBlade" + echo "" + echo "[INFO] After the customer admin accepts Tenant Admin consent, run:" + echo " bash $0 $TARGET" +} + +# ── Helper: get SP OID in tenant ────────────────────────────────────────────── +get_sp_oid() { + local token="$1" + local app_id="$2" + local tenant_id="$3" + local resp + resp=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $token" \ + -G \ + --data-urlencode "\$filter=appId eq '${app_id}'" \ + --data-urlencode "\$select=id,displayName" \ + "https://graph.microsoft.com/v1.0/servicePrincipals") + echo "$resp" | jq -r '.value[0].id // empty' +} + +# ── Helper: check if role already assigned ──────────────────────────────────── +role_assigned() { + local token="$1" + local sp_oid="$2" + local role_id="$3" + local resp + resp=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $token" \ + "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?\$filter=principalId eq '${sp_oid}'") + echo "$resp" | jq --arg rid "$role_id" \ + '[.value[] | select(.roleDefinitionId == $rid)] | length > 0' +} + +# ── Helper: assign directory role ───────────────────────────────────────────── +assign_role() { + local token="$1" + local sp_oid="$2" + local role_id="$3" + local role_name="$4" + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would assign $role_name to SP $sp_oid" + return 0 + fi + + local body + body=$(jq -n \ + --arg role "$role_id" \ + --arg principal "$sp_oid" \ + '{"roleDefinitionId": $role, "principalId": $principal, "directoryScopeId": "/"}') + + local resp + resp=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -X POST \ + "https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments" \ + -d "$body") + + local assigned_id + assigned_id=$(echo "$resp" | jq -r '.id // empty') + if [[ -z "$assigned_id" ]]; then + # Already assigned returns a conflict; treat that as OK + local err_code + err_code=$(echo "$resp" | jq -r '.error.code // empty') + if [[ "$err_code" == "Conflict" ]] || [[ "$err_code" == "Request_MultipleObjectsWithSameKeyValue" ]] || \ + echo "$resp" | grep -qi "conflicting object"; then + echo " [OK] $role_name already assigned (conflict returned — idempotent)" + return 0 + fi + echo " [ERROR] Failed to assign $role_name" >&2 + echo " Response: $resp" >&2 + return 1 + fi + echo " [OK] $role_name assigned (assignment id=$assigned_id)" +} + +# ── Step 1: Resolve tenant ──────────────────────────────────────────────────── +echo "[INFO] Resolving tenant: $TARGET" +TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TARGET") +if [[ -z "$TENANT_ID" ]]; then + echo "[ERROR] Could not resolve tenant ID for: $TARGET" >&2 + exit 1 +fi + +# Attempt to display the domain in output even if input was a GUID +DISPLAY_NAME="$TARGET" +if [[ "$TARGET" == "$TENANT_ID" ]]; then + DISPLAY_NAME="$TENANT_ID" +fi +echo "[OK] Tenant: $DISPLAY_NAME ($TENANT_ID)" + +# ── Step 2: Acquire Tenant Admin token ─────────────────────────────────────── +echo "[INFO] Acquiring Tenant Admin token for $TENANT_ID..." +TENANT_ADMIN_TOKEN="" +TOKEN_ERR="" +# Capture both stdout and stderr; get-token.sh exits non-zero on failure +set +e +TENANT_ADMIN_TOKEN_OUT=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" "tenant-admin" 2>/tmp/onboard-token-err.txt) +GET_TOKEN_EXIT=$? +TOKEN_ERR=$(cat /tmp/onboard-token-err.txt 2>/dev/null || true) +set -e + +if [[ $GET_TOKEN_EXIT -ne 0 ]]; then + # Check for AADSTS7000229 (SP not consented / not found) + if echo "$TOKEN_ERR" | grep -qi "7000229\|AADSTS7000229\|service principal\|not been authorized\|not found"; then + echo "[WARNING] Tenant Admin app not yet consented in tenant $TENANT_ID" + print_consent_urls "$TENANT_ID" + exit 2 + fi + echo "[ERROR] Failed to acquire Tenant Admin token (exit $GET_TOKEN_EXIT)" >&2 + echo "$TOKEN_ERR" >&2 + exit 5 +fi +TENANT_ADMIN_TOKEN="$TENANT_ADMIN_TOKEN_OUT" + +# Verify the SP exists (belt-and-suspenders) +TA_SP_OID=$(get_sp_oid "$TENANT_ADMIN_TOKEN" "$APP_TENANT_ADMIN" "$TENANT_ID") +if [[ -z "$TA_SP_OID" ]]; then + echo "[WARNING] Tenant Admin SP not found in tenant — app not consented yet" + print_consent_urls "$TENANT_ID" + exit 2 +fi +echo "[OK] Tenant Admin consented -- SP: $TA_SP_OID" + +[[ "$DRY_RUN" == "true" ]] && echo "[INFO] --dry-run mode: no changes will be made" + +# ── Step 3 & 4: Check/assign roles per SP ───────────────────────────────────── + +# Track overall status for final table +declare -A STATUS_MAP # key = "SP_NAME:ROLE_NAME", value = "OK" | "ASSIGNED" | "ERROR" | "DRY-RUN" + +# Parallel approach: fetch all SP OIDs first, then process roles +echo "" +echo "[INFO] Checking service principal presence in tenant..." + +SEC_INV_OID=$(get_sp_oid "$TENANT_ADMIN_TOKEN" "$APP_SEC_INV" "$TENANT_ID") +USER_MGR_OID=$(get_sp_oid "$TENANT_ADMIN_TOKEN" "$APP_USER_MGR" "$TENANT_ID") + +PARTIAL_FAILURE=false + +# ── Security Investigator -> Exchange Administrator ─────────────────────────── +if [[ -z "$SEC_INV_OID" ]]; then + echo "[WARNING] Security Investigator SP not found in tenant (not consented)" + echo " Consent URL:" + echo " ${CONSENT_BASE}/${TENANT_ID}/adminconsent?client_id=${APP_SEC_INV}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + STATUS_MAP["Security Investigator:Exchange Administrator"]="MISSING SP" +else + echo "" + echo "[CHECK] Security Investigator SP: $SEC_INV_OID" + IS_PRESENT=$(role_assigned "$TENANT_ADMIN_TOKEN" "$SEC_INV_OID" "$ROLE_EXCHANGE_ADMIN") + if [[ "$IS_PRESENT" == "true" ]]; then + echo " Exchange Administrator: PRESENT" + STATUS_MAP["Security Investigator:Exchange Administrator"]="OK" + else + echo " Exchange Administrator: MISSING -> ASSIGNING..." + if assign_role "$TENANT_ADMIN_TOKEN" "$SEC_INV_OID" "$ROLE_EXCHANGE_ADMIN" "Exchange Administrator"; then + if [[ "$DRY_RUN" == "true" ]]; then + STATUS_MAP["Security Investigator:Exchange Administrator"]="DRY-RUN" + else + STATUS_MAP["Security Investigator:Exchange Administrator"]="ASSIGNED" + fi + else + STATUS_MAP["Security Investigator:Exchange Administrator"]="ERROR" + PARTIAL_FAILURE=true + fi + fi +fi + +# ── User Manager -> User Administrator + Authentication Administrator ────────── +if [[ -z "$USER_MGR_OID" ]]; then + echo "[WARNING] User Manager SP not found in tenant (not consented)" + echo " Consent URL:" + echo " ${CONSENT_BASE}/${TENANT_ID}/adminconsent?client_id=${APP_USER_MGR}&redirect_uri=${CONSENT_REDIRECT}&prompt=consent" + STATUS_MAP["User Manager:User Administrator"]="MISSING SP" + STATUS_MAP["User Manager:Authentication Administrator"]="MISSING SP" +else + echo "" + echo "[CHECK] User Manager SP: $USER_MGR_OID" + + IS_UA=$(role_assigned "$TENANT_ADMIN_TOKEN" "$USER_MGR_OID" "$ROLE_USER_ADMIN") + if [[ "$IS_UA" == "true" ]]; then + echo " User Administrator: PRESENT" + STATUS_MAP["User Manager:User Administrator"]="OK" + else + echo " User Administrator: MISSING -> ASSIGNING..." + if assign_role "$TENANT_ADMIN_TOKEN" "$USER_MGR_OID" "$ROLE_USER_ADMIN" "User Administrator"; then + if [[ "$DRY_RUN" == "true" ]]; then + STATUS_MAP["User Manager:User Administrator"]="DRY-RUN" + else + STATUS_MAP["User Manager:User Administrator"]="ASSIGNED" + fi + else + STATUS_MAP["User Manager:User Administrator"]="ERROR" + PARTIAL_FAILURE=true + fi + fi + + IS_AA=$(role_assigned "$TENANT_ADMIN_TOKEN" "$USER_MGR_OID" "$ROLE_AUTH_ADMIN") + if [[ "$IS_AA" == "true" ]]; then + echo " Authentication Administrator: PRESENT" + STATUS_MAP["User Manager:Authentication Administrator"]="OK" + else + echo " Authentication Administrator: MISSING -> ASSIGNING..." + if assign_role "$TENANT_ADMIN_TOKEN" "$USER_MGR_OID" "$ROLE_AUTH_ADMIN" "Authentication Administrator"; then + if [[ "$DRY_RUN" == "true" ]]; then + STATUS_MAP["User Manager:Authentication Administrator"]="DRY-RUN" + else + STATUS_MAP["User Manager:Authentication Administrator"]="ASSIGNED" + fi + else + STATUS_MAP["User Manager:Authentication Administrator"]="ERROR" + PARTIAL_FAILURE=true + fi + fi +fi + +# ── Step 6: Final status table ──────────────────────────────────────────────── +echo "" +if [[ "$PARTIAL_FAILURE" == "true" ]]; then + echo "[WARNING] Onboarding completed with errors for $DISPLAY_NAME" +else + if [[ "$DRY_RUN" == "true" ]]; then + echo "[INFO] Dry-run complete for $DISPLAY_NAME ($TENANT_ID) — no changes made" + else + echo "[SUCCESS] Onboarding complete for $DISPLAY_NAME" + fi +fi + +echo "SP roles status:" +# Security Investigator +SEC_EXCH="${STATUS_MAP["Security Investigator:Exchange Administrator"]:-SKIPPED}" +echo " Security Investigator:" +printf " Exchange Administrator: %s\n" "[$SEC_EXCH]" + +# User Manager +UA="${STATUS_MAP["User Manager:User Administrator"]:-SKIPPED}" +AA="${STATUS_MAP["User Manager:Authentication Administrator"]:-SKIPPED}" +echo " User Manager:" +printf " User Administrator: %s\n" "[$UA]" +printf " Authentication Administrator: %s\n" "[$AA]" + +if [[ "$PARTIAL_FAILURE" == "true" ]]; then + exit 10 +fi +exit 0 diff --git a/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh b/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh new file mode 100644 index 0000000..03159c6 --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/patch-tenant-admin-manifest.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# Patch the Tenant Admin app manifest to add RoleManagement.ReadWrite.Directory, +# then grant admin consent for that role in the ACG home tenant. +# +# Usage: patch-tenant-admin-manifest.sh +# +# Requirements: +# - Management app token (ACG home tenant) via SOPS vault +# - jq on PATH +# - curl on PATH +set -euo pipefail + +ACG_HOME_TENANT="ce61461e-81a0-4c84-bb4a-7b354a9a356d" +MANAGEMENT_CLIENT_ID="0df4e185-4cf2-478c-a490-cc4ef36c6118" +MANAGEMENT_VAULT_PATH="msp-tools/computerguru-management.sops.yaml" +TENANT_ADMIN_APP_ID="709e6eed-0711-4875-9c44-2d3518c47063" +GRAPH_RESOURCE_APP_ID="00000003-0000-0000-c000-000000000000" +ROLE_MGMT_PERMISSION_ID="9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8" + +VAULT_ROOT="" +for candidate in "D:/vault" "$HOME/vault" "/d/vault"; do + [[ -d "$candidate" ]] && VAULT_ROOT="$candidate" && break +done +[[ -z "$VAULT_ROOT" ]] && { echo "[ERROR] SOPS vault not found (tried D:/vault ~/vault /d/vault)" >&2; exit 3; } + +# ── Step 1: Get Management app client secret ────────────────────────────────── +echo "[INFO] Reading Management app secret from vault..." +CLIENT_SECRET="" +CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$MANAGEMENT_VAULT_PATH" credentials.client_secret 2>/dev/null | tr -d '\r\n' || true) +if [[ -z "$CLIENT_SECRET" ]]; then + CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$MANAGEMENT_VAULT_PATH" credentials.credential 2>/dev/null | tr -d '\r\n' || true) +fi +[[ -z "$CLIENT_SECRET" ]] && { echo "[ERROR] Could not read secret from $MANAGEMENT_VAULT_PATH" >&2; exit 4; } +echo "[OK] Management app secret retrieved" + +# ── Step 2: Get Management app token (home tenant) ─────────────────────────── +echo "[INFO] Acquiring Management app token for ACG home tenant..." +TOKEN_RESP=$(curl -s --max-time 15 -X POST \ + "https://login.microsoftonline.com/${ACG_HOME_TENANT}/oauth2/v2.0/token" \ + --data-urlencode "client_id=${MANAGEMENT_CLIENT_ID}" \ + --data-urlencode "client_secret=${CLIENT_SECRET}" \ + --data-urlencode "scope=https://graph.microsoft.com/.default" \ + --data-urlencode "grant_type=client_credentials") + +MGMT_TOKEN=$(echo "$TOKEN_RESP" | jq -r '.access_token // empty') +if [[ -z "$MGMT_TOKEN" ]]; then + echo "[ERROR] Failed to acquire Management app token" >&2 + echo "$TOKEN_RESP" >&2 + exit 5 +fi +echo "[OK] Management app token acquired" + +# ── Step 3: Get current Tenant Admin app object + requiredResourceAccess ────── +echo "[INFO] Fetching Tenant Admin app registration (appId=$TENANT_ADMIN_APP_ID)..." +APP_RESP=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + -G \ + --data-urlencode "\$filter=appId eq '$TENANT_ADMIN_APP_ID'" \ + --data-urlencode "\$select=id,displayName,requiredResourceAccess" \ + "https://graph.microsoft.com/v1.0/applications") + +APP_OBJ_ID=$(echo "$APP_RESP" | jq -r '.value[0].id // empty') +APP_DISPLAY=$(echo "$APP_RESP" | jq -r '.value[0].displayName // empty') +if [[ -z "$APP_OBJ_ID" ]]; then + echo "[ERROR] Tenant Admin application not found (appId=$TENANT_ADMIN_APP_ID)" >&2 + echo "Response: $APP_RESP" >&2 + exit 6 +fi +echo "[OK] Found app: $APP_DISPLAY (objectId=$APP_OBJ_ID)" + +# ── Step 4: Check whether RoleManagement.ReadWrite.Directory already present ── +ALREADY_PRESENT=$(echo "$APP_RESP" | jq --arg pid "$ROLE_MGMT_PERMISSION_ID" \ + '[.value[0].requiredResourceAccess[].resourceAccess[].id] | map(select(. == $pid)) | length > 0') +if [[ "$ALREADY_PRESENT" == "true" ]]; then + echo "[OK] RoleManagement.ReadWrite.Directory already present in manifest — no patch needed" +else + echo "[INFO] RoleManagement.ReadWrite.Directory not in manifest — patching..." + + # Build updated requiredResourceAccess: inject new permission into the Graph entry + UPDATED_RRA=$(echo "$APP_RESP" | jq --arg gid "$GRAPH_RESOURCE_APP_ID" \ + --arg pid "$ROLE_MGMT_PERMISSION_ID" ' + .value[0].requiredResourceAccess + | map( + if .resourceAppId == $gid + then .resourceAccess += [{"id": $pid, "type": "Role"}] + else . + end + ) + ') + + # PATCH the application + PATCH_RESP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" -X PATCH \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + -H "Content-Type: application/json" \ + "https://graph.microsoft.com/v1.0/applications/$APP_OBJ_ID" \ + -d "{\"requiredResourceAccess\": $UPDATED_RRA}") + + if [[ "$PATCH_RESP" == "204" ]]; then + echo "[OK] App manifest patched (HTTP 204)" + else + echo "[ERROR] PATCH returned HTTP $PATCH_RESP" >&2 + exit 7 + fi +fi + +# ── Step 5: Locate Tenant Admin SP and Graph SP in the home tenant ───────────── +echo "[INFO] Locating Tenant Admin service principal in home tenant..." +TA_SP_RESP=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + -G \ + --data-urlencode "\$filter=appId eq '$TENANT_ADMIN_APP_ID'" \ + --data-urlencode "\$select=id,displayName" \ + "https://graph.microsoft.com/v1.0/servicePrincipals") +TA_SP_OID=$(echo "$TA_SP_RESP" | jq -r '.value[0].id // empty') +[[ -z "$TA_SP_OID" ]] && { echo "[ERROR] Tenant Admin SP not found in home tenant" >&2; exit 8; } +echo "[OK] Tenant Admin SP: $TA_SP_OID" + +echo "[INFO] Locating Microsoft Graph SP in home tenant..." +GRAPH_SP_RESP=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + -G \ + --data-urlencode "\$filter=appId eq '00000003-0000-0000-c000-000000000000'" \ + --data-urlencode "\$select=id" \ + "https://graph.microsoft.com/v1.0/servicePrincipals") +GRAPH_SP_OID=$(echo "$GRAPH_SP_RESP" | jq -r '.value[0].id // empty') +[[ -z "$GRAPH_SP_OID" ]] && { echo "[ERROR] Microsoft Graph SP not found in home tenant" >&2; exit 8; } +echo "[OK] Microsoft Graph SP: $GRAPH_SP_OID" + +# ── Step 6: Check if appRoleAssignment already granted ──────────────────────── +echo "[INFO] Checking existing appRoleAssignments for Tenant Admin SP..." +EXISTING_RESP=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + "https://graph.microsoft.com/v1.0/servicePrincipals/$TA_SP_OID/appRoleAssignments") + +ALREADY_GRANTED=$(echo "$EXISTING_RESP" | jq --arg rid "$ROLE_MGMT_PERMISSION_ID" \ + '[.value[] | select(.appRoleId == $rid)] | length > 0') + +if [[ "$ALREADY_GRANTED" == "true" ]]; then + echo "[OK] RoleManagement.ReadWrite.Directory appRoleAssignment already granted in home tenant — nothing to do" +else + echo "[INFO] Granting RoleManagement.ReadWrite.Directory appRoleAssignment in home tenant..." + GRANT_BODY=$(jq -n \ + --arg principal "$TA_SP_OID" \ + --arg resource "$GRAPH_SP_OID" \ + --arg role "$ROLE_MGMT_PERMISSION_ID" \ + '{"principalId": $principal, "resourceId": $resource, "appRoleId": $role}') + + GRANT_RESP=$(curl -s --max-time 15 \ + -H "Authorization: Bearer $MGMT_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST \ + "https://graph.microsoft.com/v1.0/servicePrincipals/$TA_SP_OID/appRoleAssignments" \ + -d "$GRANT_BODY") + + GRANT_ID=$(echo "$GRANT_RESP" | jq -r '.id // empty') + if [[ -z "$GRANT_ID" ]]; then + echo "[ERROR] Failed to grant appRoleAssignment" >&2 + echo "$GRANT_RESP" >&2 + exit 9 + fi + echo "[OK] appRoleAssignment granted (id=$GRANT_ID)" +fi + +echo "" +echo "[SUCCESS] Tenant Admin app manifest patched and home-tenant consent granted." +echo " RoleManagement.ReadWrite.Directory (id=$ROLE_MGMT_PERMISSION_ID) is now active." +echo "" +echo "[INFO] Next step: re-run admin consent in any customer tenants where Tenant Admin" +echo " is already consented, so the new permission is reflected in their service principal." +echo "" +echo " Consent URL pattern:" +echo " https://login.microsoftonline.com/{TENANT_ID}/adminconsent?client_id=709e6eed-0711-4875-9c44-2d3518c47063&redirect_uri=https://azcomputerguru.com&prompt=consent"