#!/usr/bin/env bash # Reset an M365 user's password via Graph (app-only, tenant-admin tier). # # Usage: reset-password.sh [--force-change] # --force-change set forceChangePasswordNextSignIn=true (default: false / permanent) # # Why this script exists: # A plain PATCH of passwordProfile works for ordinary members, but Microsoft # protects admin-role holders: resetting the password of a user who holds a # directory role (e.g. SharePoint/Teams/User Administrator) requires the CALLER # to hold Global Administrator or Privileged Authentication Administrator. The # Tenant Admin app has User.ReadWrite.All but no standing directory role, so it # gets 403 on admin targets. # # This script does a JUST-IN-TIME elevation: if the direct reset 403s, it # assigns the Tenant Admin service principal the Privileged Authentication # Administrator role (the app already holds RoleManagement.ReadWrite.Directory), # retries the reset, then REMOVES the role assignment it created. No standing # super-privilege is left behind. If the SP already held the role, it is left # untouched. # # Output: human-readable status to stdout. Exit 0 on success. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" __ROOT="${CLAUDETOOLS_ROOT:-$(cd "$SCRIPT_DIR/../../../.." && pwd)}" TENANT_INPUT="${1:?usage: reset-password.sh [--force-change]}" UPN="${2:?usage: reset-password.sh [--force-change]}" NEWPW="${3:?usage: reset-password.sh [--force-change]}" FORCE_CHANGE="false" [[ "${4:-}" == "--force-change" ]] && FORCE_CHANGE="true" # Privileged Authentication Administrator (built-in role template / definition id) PAA_ROLE_ID="7be44c8a-adaf-4e2a-84d6-ab2649e08a13" TENANT_ADMIN_APPID="709e6eed-0711-4875-9c44-2d3518c47063" TENANT_ID=$("$SCRIPT_DIR/resolve-tenant.sh" "$TENANT_INPUT") TOKEN=$("$SCRIPT_DIR/get-token.sh" "$TENANT_ID" tenant-admin) GH=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json") G="https://graph.microsoft.com/v1.0" # --- resolve target user object id --- UID_=$(curl -s "${GH[@]}" "$G/users/${UPN}?\$select=id" | tr -d '\000-\037' \ | python -c "import sys,json;print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true) [[ -z "$UID_" ]] && { echo "[ERROR] user not found: $UPN" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: target user not found / Graph returned no id" --context "tenant=$TENANT_ID upn=$UPN" >/dev/null 2>&1 || true; exit 1; } echo "[info] tenant=$TENANT_ID target=$UPN id=$UID_ force_change=$FORCE_CHANGE" # --- build payload (single-quoted heredoc would block $NEWPW; use python to emit JSON safely) --- PAYLOAD=$(NEWPW="$NEWPW" FC="$FORCE_CHANGE" python -c "import os,json;print(json.dumps({'passwordProfile':{'password':os.environ['NEWPW'],'forceChangePasswordNextSignIn':os.environ['FC']=='true'}}))") do_patch() { curl -s -o /dev/null -w "%{http_code}" -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" } CODE=$(do_patch) if [[ "$CODE" == "204" ]]; then echo "[OK] password reset for $UPN (no elevation needed)" exit 0 fi if [[ "$CODE" != "403" ]]; then echo "[ERROR] unexpected HTTP $CODE on password PATCH" >&2 curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2 bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: unexpected HTTP on password PATCH" --context "tenant=$TENANT_ID upn=$UPN http=$CODE" >/dev/null 2>&1 || true exit 1 fi echo "[info] 403 on direct reset (target likely holds an admin role) -> JIT elevation" # --- resolve tenant-admin SP object id --- SPID=$(curl -s "${GH[@]}" "$G/servicePrincipals(appId='$TENANT_ADMIN_APPID')?\$select=id" | tr -d '\000-\037' \ | python -c "import sys,json;print(json.load(sys.stdin).get('id',''))") [[ -z "$SPID" ]] && { echo "[ERROR] could not resolve Tenant Admin service principal" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: could not resolve Tenant Admin SP for JIT elevation" --context "tenant=$TENANT_ID" >/dev/null 2>&1 || true; exit 1; } # --- does the SP already hold Privileged Authentication Administrator? --- EXISTING=$(curl -s "${GH[@]}" "$G/roleManagement/directory/roleAssignments?\$filter=principalId+eq+'$SPID'+and+roleDefinitionId+eq+'$PAA_ROLE_ID'" \ | tr -d '\000-\037' | python -c "import sys,json;v=json.load(sys.stdin).get('value',[]);print(v[0]['id'] if v else '')" 2>/dev/null || true) CREATED_ASSIGNMENT="" if [[ -n "$EXISTING" ]]; then echo "[info] SP already holds Privileged Authentication Administrator (standing) -> not modifying role" else ASSIGN_BODY=$(SPID="$SPID" RID="$PAA_ROLE_ID" python -c "import os,json;print(json.dumps({'principalId':os.environ['SPID'],'roleDefinitionId':os.environ['RID'],'directoryScopeId':'/'}))") CREATED_ASSIGNMENT=$(curl -s -X POST "${GH[@]}" "$G/roleManagement/directory/roleAssignments" --data-binary "$ASSIGN_BODY" \ | tr -d '\000-\037' | python -c "import sys,json;d=json.load(sys.stdin);print(d.get('id',''))" 2>/dev/null || true) [[ -z "$CREATED_ASSIGNMENT" ]] && { echo "[ERROR] failed to assign Privileged Authentication Administrator to SP" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: failed to assign Privileged Authentication Administrator to SP (JIT elevation)" --context "tenant=$TENANT_ID sp=$SPID" >/dev/null 2>&1 || true; exit 1; } echo "[info] assigned Privileged Authentication Administrator to SP (assignment $CREATED_ASSIGNMENT)" fi # --- de-elevation runs no matter how we exit, but only removes what WE created --- cleanup() { if [[ -n "$CREATED_ASSIGNMENT" ]]; then DC=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${GH[@]}" "$G/roleManagement/directory/roleAssignments/$CREATED_ASSIGNMENT") if [[ "$DC" == "204" ]]; then echo "[info] removed JIT role assignment (de-elevated)"; else echo "[WARNING] failed to remove JIT role assignment $CREATED_ASSIGNMENT (HTTP $DC) - REMOVE MANUALLY" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: failed to remove JIT Privileged Auth Admin role - standing privilege left behind, REMOVE MANUALLY" --context "tenant=$TENANT_ID assignment=$CREATED_ASSIGNMENT http=$DC" >/dev/null 2>&1 || true; fi fi } trap cleanup EXIT # --- retry the reset; role propagation can take a few seconds --- for i in 1 2 3 4 5 6; do sleep 10 CODE=$(do_patch) if [[ "$CODE" == "204" ]]; then echo "[OK] password reset for $UPN (via JIT Privileged Authentication Administrator)" exit 0 fi echo "[info] attempt $i: HTTP $CODE (waiting for role propagation)" done echo "[ERROR] password reset still failing after elevation (last HTTP $CODE)" >&2 curl -s -X PATCH "${GH[@]}" "$G/users/$UID_" --data-binary "$PAYLOAD" | tr -d '\000-\037' >&2 bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: reset still failing after JIT elevation + retries" --context "tenant=$TENANT_ID upn=$UPN http=$CODE" >/dev/null 2>&1 || true exit 1