Files
claudetools/.claude/skills/remediation-tool/scripts/assign-exchange-role.sh
Mike Swanson 9960da5f9a harness: fleet-wide functional-error + correction + friction logging
Add .claude/scripts/log-skill-error.sh — the canonical agent error log helper
(writes errorlog.md in DATE | MACHINE | skill | [type] error format, soft-fails).
Three categories: execution failures (default), user corrections (--correction),
and preventable self-inflicted friction (--friction; cite ref= when it repeats a
documented gotcha). Goal: stop paying tokens twice for the same avoidable mistake.

- CLAUDE.md: make logging mandatory for all skills + corrections + friction.
- skill-creator: new skills must wire in the helper (guidance + checklist).
- Retrofit every skill script's genuine failure branches to call the helper
  (b2/bitdefender/mailprotector/packetdial/coord python CLIs; remediation-tool
  + onboard365 bash; vault, rmm-auth, post-bot-alert, agy, grok, 1password,
  run-onboarding-diagnostic). Handled conditions + self-tests left alone.
- errorlog.md: broaden header to cover skills + harness + corrections; seed this
  session's corrections (INKY, Mail.Send token-audience, omnibox-strictness) and
  friction (git-bash /tmp, env-persistence, argv-limit, PowerShell var-case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:40:25 -07:00

109 lines
6.6 KiB
Bash

#!/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
# Use the AUTHORITATIVE unified role-assignment API (roleManagement/directory/roleAssignments)
# for both the idempotency check and the write. The legacy directoryRoles/{id}/members list
# reads back unreliably (replication lag) and falsely reports not-assigned; roleAssignments is
# consistent. For built-in roles, roleDefinitionId == the roleTemplateId.
present="$(gget "$tok" "$GRAPH/roleManagement/directory/roleAssignments?\$filter=principalId%20eq%20'$sp_id'%20and%20roleDefinitionId%20eq%20'$EXCH_ADMIN_TEMPLATE'" | jqr '.value | 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/roleManagement/directory/roleAssignments" \
-H "Authorization: Bearer $tok" -H "Content-Type: application/json" \
-d "{\"principalId\":\"$sp_id\",\"roleDefinitionId\":\"$EXCH_ADMIN_TEMPLATE\",\"directoryScopeId\":\"/\"}")"
body="$(tr -d '\000' </tmp/aer_resp.$$ 2>/dev/null)"; rm -f /tmp/aer_resp.$$ 2>/dev/null
case "$rc" in
201) echo "ASSIGNED (Exchange Admin -> Exchange Operator SP)" ;;
400) if echo "$body" | grep -qiE 'conflicting object|already (exist|present)'; then echo "OK (already assigned)"
else echo "ERROR (HTTP 400: $(echo "$body" | jqr '.error.message // .' | head -c 120))"
bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: role assignment POST failed" --context "tenant=$tgt http=400 msg=$(echo "$body" | jqr '.error.message // .' | head -c 80)" >/dev/null 2>&1 || true; fi ;;
*) echo "ERROR (HTTP $rc: $(echo "$body" | jqr '.error.message // .' | head -c 120))"
bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: role assignment POST failed" --context "tenant=$tgt http=$rc msg=$(echo "$body" | jqr '.error.message // .' | head -c 80)" >/dev/null 2>&1 || true ;;
esac
}
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; bash "$REPO_ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "assign-exchange-role: --all run but references/tenants.md not found" --context "path=$TENANTS_MD" >/dev/null 2>&1 || true; 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.)"