sync: auto-sync from GURU-5070 at 2026-06-08 08:34:06
Author: Mike Swanson Machine: GURU-5070 Timestamp: 2026-06-08 08:34:06
This commit is contained in:
@@ -162,11 +162,13 @@ Allowed actions and which tier handles them:
|
||||
|---|---|---|
|
||||
| `revoke-sessions` | `user-manager` | Graph `POST /users/{upn}/revokeSignInSessions` |
|
||||
| `disable-account` | `user-manager` | Graph `PATCH /users/{upn}` with `accountEnabled: false` |
|
||||
| `password-reset` | `user-manager` | Graph `PATCH /users/{upn}` with new `passwordProfile` |
|
||||
| `password-reset` | `tenant-admin` | `scripts/reset-password.sh <tenant> <upn> <new-pw> [--force-change]` (Graph `PATCH /users/{upn}` passwordProfile, with JIT admin elevation — see note) |
|
||||
| `disable-forwarding` | `exchange-op` | Exchange REST `Set-Mailbox -ForwardingAddress $null -ForwardingSmtpAddress $null -DeliverToMailboxAndForward $false` |
|
||||
| `remove-inbox-rules` | `exchange-op` | Exchange REST `Remove-InboxRule` per non-default rule (ask which to keep first) |
|
||||
| `disable-smtp-auth` | `exchange-op` | Exchange REST `Set-CASMailbox -SmtpClientAuthenticationDisabled $true` |
|
||||
|
||||
**Password reset of admin-role accounts (JIT elevation):** A plain `passwordProfile` PATCH works for ordinary members but returns `403 Authorization_RequestDenied` when the target holds a directory role (SharePoint/Teams/User Admin, etc.) — Microsoft requires the caller to be Global Administrator or **Privileged Authentication Administrator** to reset an admin's password. `scripts/reset-password.sh` handles this: it tries the direct reset, and on 403 it assigns the Tenant Admin service principal the Privileged Authentication Administrator role (the app holds `RoleManagement.ReadWrite.Directory`), retries, then **removes the role assignment it created** (de-elevates). If the SP already held the role, it is left untouched. Default `forceChangePasswordNextSignIn=false` (permanent — right for shared/service accounts); pass `--force-change` for a user who must change at next sign-in. Requires the tenant to have consented the Tenant Admin app. (Pattern added 2026-06-08 — birthbiologic.com operations@ was a SharePoint+Teams Admin, blocking the plain reset.)
|
||||
|
||||
---
|
||||
|
||||
## Arguments
|
||||
|
||||
111
.claude/skills/remediation-tool/scripts/reset-password.sh
Normal file
111
.claude/skills/remediation-tool/scripts/reset-password.sh
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# Reset an M365 user's password via Graph (app-only, tenant-admin tier).
|
||||
#
|
||||
# Usage: reset-password.sh <tenant-id-or-domain> <upn> <new-password> [--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)"
|
||||
|
||||
TENANT_INPUT="${1:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--force-change]}"
|
||||
UPN="${2:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--force-change]}"
|
||||
NEWPW="${3:?usage: reset-password.sh <tenant|domain> <upn> <new-password> [--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; 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
|
||||
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; 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; 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; 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
|
||||
exit 1
|
||||
Reference in New Issue
Block a user