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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
310
.claude/skills/remediation-tool/scripts/onboard-tenant.sh
Normal file
310
.claude/skills/remediation-tool/scripts/onboard-tenant.sh
Normal 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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user