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>
166 lines
7.0 KiB
Bash
166 lines
7.0 KiB
Bash
#!/usr/bin/env bash
|
|
# Acquire a client-credentials bearer token for a ComputerGuru MSP app tier.
|
|
# Usage: get-token.sh <tenant-id-or-domain> <tier>
|
|
#
|
|
# Tiers and their app + resource scope:
|
|
# investigator ComputerGuru Security Investigator -> Graph API (read-only breach checks)
|
|
# investigator-exo ComputerGuru Security Investigator -> Exchange Online (EXO read: Get-InboxRule, Get-Mailbox)
|
|
# exchange-op ComputerGuru Exchange Operator -> Exchange Online (EXO write: Set-Mailbox, Remove-InboxRule, revoke sessions)
|
|
# user-manager ComputerGuru User Manager -> Graph API (user create/update/disable, license assign, MFA reset)
|
|
# tenant-admin ComputerGuru Tenant Admin -> Graph API (app roles, CA policy, directory write — high privilege)
|
|
# defender ComputerGuru Defender Add-on -> Defender ATP API (MDE-licensed tenants only)
|
|
#
|
|
# Output (stdout): bearer token. Exits 0 on success.
|
|
# Cache: /tmp/remediation-tool/{tenant-id}/{tier}.jwt TTL 55 minutes.
|
|
set -euo pipefail
|
|
|
|
TARGET="${1:?usage: get-token.sh <tenant-id|domain> <tier>}"
|
|
TIER="${2:?usage: get-token.sh <tenant-id|domain> <tier>}"
|
|
|
|
# Resolve domain to tenant GUID if needed
|
|
if [[ "$TARGET" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]]; then
|
|
TENANT_ID="$TARGET"
|
|
else
|
|
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
|
|
TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TARGET")
|
|
fi
|
|
|
|
# Map tier -> client_id, vault SOPS path, resource scope
|
|
case "$TIER" in
|
|
investigator)
|
|
CLIENT_ID="bfbc12a4-f0dd-4e12-b06d-997e7271e10c"
|
|
VAULT_PATH="msp-tools/computerguru-security-investigator.sops.yaml"
|
|
SCOPE_URL="https://graph.microsoft.com/.default"
|
|
;;
|
|
investigator-exo)
|
|
CLIENT_ID="bfbc12a4-f0dd-4e12-b06d-997e7271e10c"
|
|
VAULT_PATH="msp-tools/computerguru-security-investigator.sops.yaml"
|
|
SCOPE_URL="https://outlook.office365.com/.default"
|
|
;;
|
|
exchange-op)
|
|
CLIENT_ID="b43e7342-5b4b-492f-890f-bb5a4f7f40e9"
|
|
VAULT_PATH="msp-tools/computerguru-exchange-operator.sops.yaml"
|
|
SCOPE_URL="https://outlook.office365.com/.default"
|
|
;;
|
|
user-manager)
|
|
CLIENT_ID="64fac46b-8b44-41ad-93ee-7da03927576c"
|
|
VAULT_PATH="msp-tools/computerguru-user-manager.sops.yaml"
|
|
SCOPE_URL="https://graph.microsoft.com/.default"
|
|
;;
|
|
tenant-admin)
|
|
CLIENT_ID="709e6eed-0711-4875-9c44-2d3518c47063"
|
|
VAULT_PATH="msp-tools/computerguru-tenant-admin.sops.yaml"
|
|
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)
|
|
CLIENT_ID="dbf8ad1a-54f4-4bb8-8a9e-ea5b9634635b"
|
|
VAULT_PATH="msp-tools/computerguru-defender-addon.sops.yaml"
|
|
SCOPE_URL="https://api.securitycenter.microsoft.com/.default"
|
|
;;
|
|
*)
|
|
echo "ERROR: unknown tier '$TIER'." >&2
|
|
echo "Valid tiers: investigator | investigator-exo | exchange-op | user-manager | tenant-admin | tenant-admin-onboard | defender" >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
|
|
CACHE_DIR="/tmp/remediation-tool/$TENANT_ID"
|
|
mkdir -p "$CACHE_DIR"
|
|
CACHE_FILE="$CACHE_DIR/${TIER}.jwt"
|
|
|
|
# Return cached token if < 55 minutes old
|
|
if [[ -f "$CACHE_FILE" ]] && [[ $(find "$CACHE_FILE" -mmin -55 2>/dev/null) ]]; then
|
|
cat "$CACHE_FILE"
|
|
exit 0
|
|
fi
|
|
|
|
# Locate vault repo
|
|
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; }
|
|
|
|
SOPS_FILE="$VAULT_ROOT/$VAULT_PATH"
|
|
[[ ! -f "$SOPS_FILE" ]] && { echo "ERROR: vault file not found: $SOPS_FILE" >&2; exit 3; }
|
|
|
|
# Read client secret via vault.sh (fast path), falling back to raw sops+python
|
|
CLIENT_SECRET=""
|
|
if [[ -f "$VAULT_ROOT/scripts/vault.sh" ]]; then
|
|
# Try both field names — new files use client_secret, legacy used credential
|
|
CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$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 "$VAULT_PATH" credentials.credential 2>/dev/null | tr -d '\r\n' || true)
|
|
fi
|
|
fi
|
|
|
|
if [[ -z "$CLIENT_SECRET" ]]; then
|
|
PYTHON_BIN=""
|
|
for p in py python python3; do command -v "$p" >/dev/null 2>&1 && PYTHON_BIN="$p" && break; done
|
|
[[ -z "$PYTHON_BIN" ]] && { echo "ERROR: vault.sh failed and python unavailable for SOPS fallback" >&2; exit 3; }
|
|
command -v sops >/dev/null 2>&1 || { echo "ERROR: sops not on PATH (needed for fallback decrypt)" >&2; exit 3; }
|
|
|
|
CLIENT_SECRET=$(sops -d "$SOPS_FILE" 2>/dev/null | "$PYTHON_BIN" -c "
|
|
import sys, re
|
|
t = sys.stdin.read()
|
|
m = re.search(r'^credentials:\s*\n((?:[ \t]+.*\n)+)', t, re.MULTILINE)
|
|
if not m: sys.exit(1)
|
|
for line in m.group(1).splitlines():
|
|
line = line.strip()
|
|
if line.startswith('client_secret:') or line.startswith('credential:'):
|
|
print(line.split(':', 1)[1].strip().strip('\"').strip(\"'\"))
|
|
break
|
|
" | tr -d '\r\n')
|
|
fi
|
|
|
|
[[ -z "$CLIENT_SECRET" ]] && {
|
|
echo "ERROR: could not read secret from $VAULT_PATH" >&2
|
|
echo " Check field: credentials.client_secret (or credentials.credential for older entries)" >&2
|
|
exit 4
|
|
}
|
|
|
|
# Request token
|
|
RESP=$(curl -s --max-time 15 -X POST \
|
|
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
|
|
--data-urlencode "client_id=${CLIENT_ID}" \
|
|
--data-urlencode "client_secret=${CLIENT_SECRET}" \
|
|
--data-urlencode "scope=${SCOPE_URL}" \
|
|
--data-urlencode "grant_type=client_credentials")
|
|
|
|
TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
|
|
|
|
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 "$RESP" >&2
|
|
exit 5
|
|
fi
|
|
|
|
echo "$TOKEN" > "$CACHE_FILE"
|
|
chmod 600 "$CACHE_FILE" 2>/dev/null || true
|
|
echo "$TOKEN"
|