fix(remediation): close the recurring Exchange-Admin-role gap fleet-wide
EXO email-cleanup tasks (Search-UnifiedAuditLog, Get-MessageTrace, inbox rules) kept 401/403-ing per tenant because the Exchange Operator SP was missing the Exchange Admin directory role — admin consent grants Exchange.ManageAsApp but never the directory role. onboard-tenant.sh assigns it, but tenants consented before that step / by hand never got it, and nothing audited for it. Hence the recurring 'next onboarding will fix it' (false for already-onboarded tenants). - NEW assign-exchange-role.sh: idempotent role assignment via the authoritative roleManagement/directory/roleAssignments API (the legacy directoryRoles/members list reads back unreliably). <domain|--all> + --verify/--dry-run. - Backfilled the whole fleet (--all): 13 stragglers ASSIGNED, 12 already OK, 20 skipped (tenant-admin not consented), 0 errors. Safe Site included. - Standing audit documented (assign-exchange-role.sh --all --verify) + memory so no future session repeats the empty promise. - Adds wiki/clients/safesite.md (tenant + 4-source endpoint inventory + investigation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,30 +70,25 @@ process_one() {
|
||||
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')"
|
||||
# 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/directoryRoles/$role_id/members/\$ref" \
|
||||
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 "{\"@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
|
||||
-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))"; fi ;;
|
||||
*) echo "ERROR (HTTP $rc: $(echo "$body" | jqr '.error.message // .' | head -c 120))" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo "=== assign-exchange-role [mode=$MODE] ==="
|
||||
|
||||
Reference in New Issue
Block a user