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>
311 lines
13 KiB
Bash
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
|