Files
claudetools/.claude/skills/remediation-tool/scripts/onboard-tenant.sh
Mike Swanson cd50117aaf 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>
2026-04-20 16:56:47 -07:00

311 lines
13 KiB
Bash

#!/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