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>
173 lines
7.7 KiB
Bash
173 lines
7.7 KiB
Bash
#!/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"
|