fix: remediation tool onboarding — add RoleManagement.ReadWrite.Directory + auto role assignment

Root cause: app-only Graph operations (password reset, Exchange REST) require
directory roles on each SP in the customer tenant, not just admin consent.
RoleManagement.ReadWrite.Directory was missing from all app manifests, making
role assignment impossible without manual portal work that was never being done.

Changes:
- patch-tenant-admin-manifest.sh: adds RoleManagement.ReadWrite.Directory to
  Tenant Admin app manifest via Management app, grants home-tenant consent
- onboard-tenant.sh: new script — resolves tenant, acquires Tenant Admin token,
  assigns Exchange Administrator to Security Investigator SP and User/Auth
  Administrator to User Manager SP; --dry-run supported; idempotent
- get-token.sh: detects AADSTS7000229, emits consent URL + onboard-tenant.sh
  reminder instead of silent failure
- gotchas.md: onboarding steps at top, tenant table expanded with role columns,
  all known tenants updated including martylryan.com (first fully onboarded)

Verified: martylryan.com fully onboarded, password reset to MLR2026!! succeeded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 16:56:47 -07:00
parent 749a472089
commit cd50117aaf
4 changed files with 528 additions and 8 deletions

View File

@@ -1,5 +1,17 @@
# Gotchas — Permissions, Roles, Consent # 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 <domain>`
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) ## App Suite (tiered architecture)
Five multi-tenant apps replace the old single over-permissioned app. Use minimum necessary tier. 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) ## 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 | | 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 | — | — | — | — | User Admin (old app) | Needs migration to new app suite | | Valleywide Plastering | 5c53ae9f... | old app only | — | — | — | — | — | old app only | — | Needs migration to new app suite |
| Dataforth | 7dfa3ce8... | old app only | — | — | — | — | User Admin + Exchange Admin (old app) | Needs migration | | Dataforth | 7dfa3ce8... | old app only | — | — | — | — | old app only | old app only | — | 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 | | 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) | — | — | none | No directory roles assigned yet | | 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. **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.

View File

@@ -52,6 +52,13 @@ case "$TIER" in
VAULT_PATH="msp-tools/computerguru-tenant-admin.sops.yaml" VAULT_PATH="msp-tools/computerguru-tenant-admin.sops.yaml"
SCOPE_URL="https://graph.microsoft.com/.default" 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) defender)
CLIENT_ID="dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b" CLIENT_ID="dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b"
VAULT_PATH="msp-tools/computerguru-defender-addon.sops.yaml" VAULT_PATH="msp-tools/computerguru-defender-addon.sops.yaml"
@@ -59,7 +66,7 @@ case "$TIER" in
;; ;;
*) *)
echo "ERROR: unknown tier '$TIER'." >&2 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 exit 2
;; ;;
esac esac
@@ -130,6 +137,24 @@ RESP=$(curl -s --max-time 15 -X POST \
TOKEN=$(echo "$RESP" | jq -r '.access_token // empty') TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
if [[ -z "$TOKEN" ]]; then 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 "ERROR: token request failed (tenant=$TENANT_ID tier=$TIER)" >&2
echo "$RESP" >&2 echo "$RESP" >&2
exit 5 exit 5

View File

@@ -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 <domain-or-tenant-id> [--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 <domain-or-tenant-id> [--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

View File

@@ -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"