sync: auto-sync from GURU-5070 at 2026-06-08 19:51:00
Author: Mike Swanson Machine: GURU-5070 Timestamp: 2026-06-08 19:51:00
This commit is contained in:
111
.claude/skills/remediation-tool/scripts/assign-exchange-role.sh
Normal file
111
.claude/skills/remediation-tool/scripts/assign-exchange-role.sh
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# assign-exchange-role.sh — assign the Entra "Exchange Administrator" directory role to the
|
||||||
|
# ComputerGuru Exchange Operator service principal in a customer tenant.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS: app-only Exchange Online management (Search-UnifiedAuditLog, Get-MessageTrace,
|
||||||
|
# Get/Remove-InboxRule, Set-Mailbox, mailbox forwarding/delegate audit) requires the app's SP to
|
||||||
|
# hold BOTH the `Exchange.ManageAsApp` API permission (granted by admin consent) AND an Entra
|
||||||
|
# **directory role** (Exchange Administrator). Admin consent grants the API permission but NEVER
|
||||||
|
# the directory role — so every freshly-consented tenant 401/403s on EXO management until this one
|
||||||
|
# step is done. This script closes that gap, idempotently, and is wired into onboard-tenant.sh so
|
||||||
|
# new tenants get it automatically. Run `--all` to backfill the existing fleet.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# assign-exchange-role.sh <domain-or-tenant-id> assign for one tenant
|
||||||
|
# assign-exchange-role.sh --all every tenant in references/tenants.md
|
||||||
|
# assign-exchange-role.sh <target|--all> --verify report current state only (no writes)
|
||||||
|
# assign-exchange-role.sh <target|--all> --dry-run show what WOULD change (no writes)
|
||||||
|
#
|
||||||
|
# Requires: the tenant-admin app consented in the target tenant (it carries
|
||||||
|
# RoleManagement.ReadWrite.Directory). Tenants where tenant-admin or the Exchange Operator app is
|
||||||
|
# not consented are SKIPPED with a clear reason (not an error).
|
||||||
|
#
|
||||||
|
# Read-only by default? NO — without --verify/--dry-run it performs the role assignment (a security
|
||||||
|
# change). It is idempotent: a tenant already assigned is reported and left untouched.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
EXCHANGE_OP_APPID="b43e7342-5b4b-492f-890f-bb5a4f7f40e9" # ComputerGuru Exchange Operator
|
||||||
|
EXCH_ADMIN_TEMPLATE="29232cdf-9323-42fd-ade2-1d097af3e4de" # Entra "Exchange Administrator" roleTemplateId
|
||||||
|
GRAPH="https://graph.microsoft.com/v1.0"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
GET_TOKEN="$SCRIPT_DIR/get-token.sh"
|
||||||
|
TENANTS_MD="$SCRIPT_DIR/../references/tenants.md"
|
||||||
|
|
||||||
|
# Resolve vault_path -> VAULT_ROOT_ENV so get-token.sh works regardless of ~/.claude/identity.json.
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||||
|
if [ -z "${VAULT_ROOT_ENV:-}" ]; then
|
||||||
|
for idf in "$REPO_ROOT/.claude/identity.json" "$HOME/.claude/identity.json"; do
|
||||||
|
[ -f "$idf" ] || continue
|
||||||
|
vp="$(jq -r '.vault_path // empty' "$idf" 2>/dev/null)"
|
||||||
|
[ -n "$vp" ] && { export VAULT_ROOT_ENV="$vp"; break; }
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODE="apply"
|
||||||
|
TARGET=""
|
||||||
|
for a in "$@"; do
|
||||||
|
case "$a" in
|
||||||
|
--verify) MODE="verify" ;;
|
||||||
|
--dry-run) MODE="dryrun" ;;
|
||||||
|
--all) TARGET="--all" ;;
|
||||||
|
-h|--help) grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||||
|
*) TARGET="$a" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$TARGET" ] || { echo "[ERROR] need a tenant (domain/id) or --all. See --help." >&2; exit 64; }
|
||||||
|
|
||||||
|
jqr() { jq -r "$1" 2>/dev/null | tr -d '\r'; }
|
||||||
|
gget() { curl -s --max-time 25 -H "Authorization: Bearer $1" "$2" | tr -d '\000'; }
|
||||||
|
|
||||||
|
# process_one <domain-or-tenant-id>
|
||||||
|
process_one() {
|
||||||
|
local tgt="$1" tok sp_id role_id members present rc body
|
||||||
|
printf '%-42s ' "$tgt"
|
||||||
|
|
||||||
|
tok="$(VAULT_ROOT_ENV="${VAULT_ROOT_ENV:-}" bash "$GET_TOKEN" "$tgt" tenant-admin 2>/dev/null | tr -d '[:space:]')"
|
||||||
|
if [ -z "$tok" ] || [ "${#tok}" -lt 100 ]; then echo "SKIP (tenant-admin not consented)"; return; fi
|
||||||
|
|
||||||
|
sp_id="$(gget "$tok" "$GRAPH/servicePrincipals?\$filter=appId%20eq%20'$EXCHANGE_OP_APPID'&\$select=id" | jqr '.value[0].id // empty')"
|
||||||
|
if [ -z "$sp_id" ]; then echo "SKIP (Exchange Operator app not consented in tenant)"; return; fi
|
||||||
|
|
||||||
|
# find or (if applying) activate the Exchange Administrator directory role
|
||||||
|
role_id="$(gget "$tok" "$GRAPH/directoryRoles?\$filter=roleTemplateId%20eq%20'$EXCH_ADMIN_TEMPLATE'" | jqr '.value[0].id // empty')"
|
||||||
|
if [ -z "$role_id" ]; then
|
||||||
|
if [ "$MODE" = "apply" ]; then
|
||||||
|
role_id="$(curl -s --max-time 25 -X POST "$GRAPH/directoryRoles" \
|
||||||
|
-H "Authorization: Bearer $tok" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"roleTemplateId\":\"$EXCH_ADMIN_TEMPLATE\"}" | tr -d '\000' | jqr '.id // empty')"
|
||||||
|
[ -z "$role_id" ] && { echo "ERROR (could not activate Exchange Admin role)"; return; }
|
||||||
|
else
|
||||||
|
echo "WOULD activate Exchange Admin role + assign SP $sp_id"; return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
present="$(gget "$tok" "$GRAPH/directoryRoles/$role_id/members?\$select=id" | jqr --arg s "$sp_id" '[.value[]?|select(.id==$s)]|length')"
|
||||||
|
if [ "${present:-0}" -gt 0 ] 2>/dev/null; then echo "OK (already assigned)"; return; fi
|
||||||
|
|
||||||
|
if [ "$MODE" != "apply" ]; then echo "WOULD assign Exchange Admin to SP $sp_id"; return; fi
|
||||||
|
|
||||||
|
rc="$(curl -s --max-time 25 -o /tmp/aer_resp.$$ -w '%{http_code}' -X POST "$GRAPH/directoryRoles/$role_id/members/\$ref" \
|
||||||
|
-H "Authorization: Bearer $tok" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"@odata.id\":\"$GRAPH/directoryObjects/$sp_id\"}")"
|
||||||
|
if [ "$rc" = "204" ]; then echo "ASSIGNED (Exchange Admin -> Exchange Operator SP)";
|
||||||
|
else echo "ERROR (HTTP $rc: $(tr -d '\000' </tmp/aer_resp.$$ | jqr '.error.message // .' | head -c 120))"; fi
|
||||||
|
rm -f /tmp/aer_resp.$$ 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== assign-exchange-role [mode=$MODE] ==="
|
||||||
|
echo "Role: Exchange Administrator ($EXCH_ADMIN_TEMPLATE) -> SP: Exchange Operator ($EXCHANGE_OP_APPID)"
|
||||||
|
echo "------------------------------------------------------------------------"
|
||||||
|
if [ "$TARGET" = "--all" ]; then
|
||||||
|
[ -f "$TENANTS_MD" ] || { echo "[ERROR] tenants.md not found: $TENANTS_MD" >&2; exit 66; }
|
||||||
|
# extract tenant GUIDs from the markdown table (column 3)
|
||||||
|
grep -oE '[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}' "$TENANTS_MD" \
|
||||||
|
| sort -u | while read -r tid; do process_one "$tid"; done
|
||||||
|
else
|
||||||
|
process_one "$TARGET"
|
||||||
|
fi
|
||||||
|
echo "------------------------------------------------------------------------"
|
||||||
|
echo "Done. (Re-run with --verify any time to audit fleet state.)"
|
||||||
Reference in New Issue
Block a user