diff --git a/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh b/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh new file mode 100644 index 0000000..c7e508f --- /dev/null +++ b/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh @@ -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 assign for one tenant +# assign-exchange-role.sh --all every tenant in references/tenants.md +# assign-exchange-role.sh --verify report current state only (no writes) +# assign-exchange-role.sh --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 +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' /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.)"