Root-caused the recurring '365 suite isn't documented' pain: the apps are fine (tiered by privilege) but per-tenant consent is NOT uniform and there was no way to see a tenant's actual grant state. VWP had the Tenant Admin app but no SharePoint app-only role -> silent 401s until this session. - references/app-suite.md: authoritative, live-verified map of every app, App ID, and actually-granted permission per tier; the consent-drift problem + both fix methods (adminconsent URL, direct appRoleAssignment grant). - scripts/consent-audit.sh: audits a tenant (or --all) vs the baseline, grades GREEN/AMBER/RED, prints the exact fix per gap. Extends the assign-exchange-role --verify pattern to Graph scopes + SharePoint role + EXO role. Verified: BirthBio GREEN, VWP/Cascades AMBER (caught real drift - both missing grants). - SKILL.md: run consent-audit FIRST on any tenant task. Memory + errorlog correction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
9.6 KiB
Bash
Executable File
185 lines
9.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# consent-audit.sh — audit a tenant's ACTUAL consent state for the ComputerGuru M365 app
|
|
# suite against the documented baseline, report every gap with the exact fix command, and
|
|
# grade GREEN / AMBER / RED. Extends the assign-exchange-role.sh --verify pattern to ALL
|
|
# permissions (Graph scopes + SharePoint app-only role + EXO directory role), so a
|
|
# partially-consented tenant (the VWP-had-the-app-but-no-SharePoint failure) is caught up
|
|
# front instead of by a 401 mid-task.
|
|
#
|
|
# Usage:
|
|
# consent-audit.sh <domain|tenant-guid> audit one tenant (full detail)
|
|
# consent-audit.sh --all [--verbose] audit every tenant in references/tenants.md
|
|
# consent-audit.sh <t> --matrix one-line matrix row (for regenerating tenants)
|
|
#
|
|
# Exit: 0 GREEN, 1 AMBER (partial), 2 RED (an app not consented / token mint failed), 3 usage.
|
|
#
|
|
# Deps: get-token.sh (+ vault), jq, curl, a python. Read-only: mints tokens + reads Graph;
|
|
# never writes. The FIXES it prints are for a human/operator to run.
|
|
|
|
set -u
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
GT="$SCRIPT_DIR/get-token.sh"
|
|
GRAPH="https://graph.microsoft.com/v1.0"
|
|
|
|
# --- app suite (appId + get-token tier + must-have roles) ------------------------------
|
|
APP_INVESTIGATOR="bfbc12a4-f0dd-4e12-b06d-997e7271e10c"
|
|
APP_EXCHANGE_OP="b43e7342-5b4b-492f-890f-bb5a4f7f40e9"
|
|
APP_USER_MANAGER="64fac46b-8b44-41ad-93ee-7da03927576c"
|
|
APP_TENANT_ADMIN="709e6eed-0711-4875-9c44-2d3518c47063"
|
|
SP_RESOURCE_APPID="00000003-0000-0ff1-ce00-000000000000" # Office 365 SharePoint Online
|
|
SP_FULLCONTROL_ROLE="Sites.FullControl.All"
|
|
EXCHANGE_ADMIN_ROLE_TEMPLATE="29232cdf-9323-42fd-ade2-1d097af3e4de" # Exchange Administrator
|
|
|
|
# must-have Graph roles per tier (curated: absence causes real task failures)
|
|
BASE_investigator="Directory.Read.All User.Read.All Sites.Read.All Mail.Read AuditLog.Read.All"
|
|
BASE_exchange_op_graph="Mail.ReadWrite MailboxSettings.ReadWrite"
|
|
BASE_user_manager="User.ReadWrite.All Group.ReadWrite.All Directory.ReadWrite.All"
|
|
BASE_tenant_admin="Application.ReadWrite.All AppRoleAssignment.ReadWrite.All Directory.ReadWrite.All RoleManagement.ReadWrite.Directory Sites.FullControl.All Sites.ReadWrite.All"
|
|
|
|
VERBOSE=0; MATRIX=0; ALL=0; TARGET=""
|
|
for a in "$@"; do
|
|
case "$a" in
|
|
--all) ALL=1 ;;
|
|
--verbose|-v) VERBOSE=1 ;;
|
|
--matrix) MATRIX=1 ;;
|
|
-h|--help) sed -n '2,22p' "$0"; exit 3 ;;
|
|
*) TARGET="$a" ;;
|
|
esac
|
|
done
|
|
|
|
jwt_roles() { # $1: token -> space-separated roles ("" if not a JWT)
|
|
python - "${1:-}" <<'PY'
|
|
import sys,base64,json
|
|
t=(sys.argv[1] if len(sys.argv)>1 else "").strip()
|
|
if t.count('.')!=2 or not t.startswith('ey'):
|
|
print(""); sys.exit(0)
|
|
try:
|
|
p=t.split('.')[1]; p+='='*(-len(p)%4)
|
|
print(" ".join(json.loads(base64.urlsafe_b64decode(p)).get("roles",[])))
|
|
except Exception:
|
|
print("")
|
|
PY
|
|
}
|
|
|
|
missing_of() { # $1=have (space list) $2=want (space list) -> missing items
|
|
local have=" $1 " out=""
|
|
for w in $2; do case "$have" in *" $w "*) ;; *) out="$out $w";; esac; done
|
|
echo "${out# }"
|
|
}
|
|
|
|
# --- resolve tenant guid + primary domain ----------------------------------------------
|
|
resolve_tenant() { # $1 = domain or guid ; sets TID + DOMAIN + SPPREFIX
|
|
local in="$1"
|
|
if [[ "$in" =~ ^[0-9a-fA-F-]{36}$ ]]; then TID="$in"; else
|
|
TID="$(curl -s "https://login.microsoftonline.com/$in/v2.0/.well-known/openid-configuration" | jq -r '.token_endpoint//""' | cut -d/ -f4)"
|
|
fi
|
|
[ -z "${TID:-}" ] && { echo "[ERROR] could not resolve tenant '$in'"; return 2; }
|
|
# onmicrosoft prefix for SharePoint host, via tenant-admin /domains
|
|
local ta; ta="$($GT "$TID" tenant-admin 2>/dev/null | tail -1)"
|
|
if [ -n "$ta" ] && printf '%s' "$ta" | grep -q '^ey'; then
|
|
local init; init="$(curl -s -G "$GRAPH/domains" --data-urlencode "\$select=id,isInitial" -H "Authorization: Bearer $ta" | jq -r '.value[]?|select(.isInitial==true)|.id')"
|
|
DOMAIN="$(curl -s -G "$GRAPH/domains" --data-urlencode "\$select=id,isDefault" -H "Authorization: Bearer $ta" | jq -r '.value[]?|select(.isDefault==true)|.id')"
|
|
SPPREFIX="${init%%.onmicrosoft.com}"
|
|
[ -z "$DOMAIN" ] && DOMAIN="$init"
|
|
else DOMAIN="$in"; SPPREFIX=""; fi
|
|
return 0
|
|
}
|
|
|
|
# --- one capability check --------------------------------------------------------------
|
|
# emits a status line; appends fixes to $FIXES; bumps $WORST (0/1/2)
|
|
check_graph_tier() { # $1=label $2=tier $3=appid $4=want-roles
|
|
local label="$1" tier="$2" appid="$3" want="$4"
|
|
rm -f "/tmp/remediation-tool/$TID/$tier.jwt" 2>/dev/null
|
|
local tok; tok="$($GT "$TID" "$tier" 2>/dev/null | tail -1)"
|
|
local roles; roles="$(jwt_roles "$tok")"
|
|
if [ -z "$roles" ] && ! printf '%s' "$tok" | grep -q '^ey'; then
|
|
printf " [RED] %-16s app NOT consented (token mint failed)\n" "$label"
|
|
FIXES="$FIXES\n # $label: consent the app\n https://login.microsoftonline.com/$TID/adminconsent?client_id=$appid"
|
|
WORST=2; return
|
|
fi
|
|
local miss; miss="$(missing_of "$roles" "$want")"
|
|
if [ -z "$miss" ]; then
|
|
printf " [OK] %-16s all baseline roles present\n" "$label"
|
|
[ "$VERBOSE" = 1 ] && echo " have: $roles"
|
|
else
|
|
printf " [AMBER] %-16s PARTIAL — missing: %s\n" "$label" "$miss"
|
|
FIXES="$FIXES\n # $label: re-consent grants the full manifest\n https://login.microsoftonline.com/$TID/adminconsent?client_id=$appid"
|
|
[ "$WORST" -lt 1 ] && WORST=1
|
|
fi
|
|
}
|
|
|
|
check_sharepoint() {
|
|
local host="${SPPREFIX:+$SPPREFIX-admin.sharepoint.com}"
|
|
rm -f "/tmp/remediation-tool/$TID/sharepoint-admin.jwt" 2>/dev/null
|
|
local tok; tok="$(SP_RESOURCE_ENV="$host" $GT "$TID" sharepoint-admin 2>/dev/null | tail -1)"
|
|
local roles; roles="$(jwt_roles "$tok")"
|
|
case " $roles " in
|
|
*" $SP_FULLCONTROL_ROLE "*)
|
|
printf " [OK] %-16s Sites.FullControl.All present (cert)\n" "SharePoint" ;;
|
|
*)
|
|
printf " [AMBER] %-16s missing SharePoint app-only Sites.FullControl.All\n" "SharePoint"
|
|
FIXES="$FIXES\n # SharePoint: adminconsent often does NOT attach this — grant the app-only role directly (Method B, app-suite.md):\n # TA=\$($GT $TID tenant-admin|tail -1); recip=Tenant-Admin SP id; res=SharePoint SP id; role=Sites.FullControl.All id\n # POST /servicePrincipals/{recip}/appRoleAssignments {principalId,resourceId,appRoleId}\n https://login.microsoftonline.com/$TID/adminconsent?client_id=$APP_TENANT_ADMIN # try re-consent first"
|
|
[ "$WORST" -lt 1 ] && WORST=1 ;;
|
|
esac
|
|
}
|
|
|
|
check_exo_role() { # Exchange Admin directory role on the Exchange Operator SP
|
|
local ta; ta="$($GT "$TID" tenant-admin 2>/dev/null | tail -1)"
|
|
printf '%s' "$ta" | grep -q '^ey' || { printf " [SKIP] %-16s (need tenant-admin to check)\n" "EXO role"; return; }
|
|
local spid; spid="$(curl -s -G "$GRAPH/servicePrincipals" --data-urlencode "\$filter=appId eq '$APP_EXCHANGE_OP'" --data-urlencode "\$select=id" -H "Authorization: Bearer $ta" | jq -r '.value[0].id//""')"
|
|
[ -z "$spid" ] && { printf " [RED] %-16s Exchange Operator app NOT consented\n" "EXO role"
|
|
FIXES="$FIXES\n # Exchange Operator: consent the app\n https://login.microsoftonline.com/$TID/adminconsent?client_id=$APP_EXCHANGE_OP"; WORST=2; return; }
|
|
local has; has="$(curl -s -G "$GRAPH/roleManagement/directory/roleAssignments" --data-urlencode "\$filter=principalId eq '$spid'" -H "Authorization: Bearer $ta" | jq -r --arg r "$EXCHANGE_ADMIN_ROLE_TEMPLATE" '[.value[]?|select(.roleDefinitionId==$r)]|length')"
|
|
if [ "${has:-0}" -ge 1 ] 2>/dev/null; then
|
|
printf " [OK] %-16s Exchange Administrator role assigned\n" "EXO role"
|
|
else
|
|
printf " [AMBER] %-16s Exchange Operator SP missing Exchange Administrator role\n" "EXO role"
|
|
FIXES="$FIXES\n # EXO: assign the Exchange Administrator role\n bash $SCRIPT_DIR/assign-exchange-role.sh $DOMAIN"
|
|
[ "$WORST" -lt 1 ] && WORST=1
|
|
fi
|
|
}
|
|
|
|
audit_one() { # $1 = domain|guid ; returns worst grade (0/1/2)
|
|
TID=""; DOMAIN=""; SPPREFIX=""; FIXES=""; WORST=0
|
|
resolve_tenant "$1" || return 2
|
|
if [ "$MATRIX" = 1 ]; then
|
|
# compact single-line probe for matrix use
|
|
:
|
|
fi
|
|
echo "============================================================"
|
|
echo " Consent audit — ${DOMAIN:-$1} ($TID)"
|
|
echo "============================================================"
|
|
check_graph_tier "investigator" investigator "$APP_INVESTIGATOR" "$BASE_investigator"
|
|
check_graph_tier "exchange-op" exchange-op-graph "$APP_EXCHANGE_OP" "$BASE_exchange_op_graph"
|
|
check_graph_tier "user-manager" user-manager "$APP_USER_MANAGER" "$BASE_user_manager"
|
|
check_graph_tier "tenant-admin" tenant-admin "$APP_TENANT_ADMIN" "$BASE_tenant_admin"
|
|
check_sharepoint
|
|
check_exo_role
|
|
local grade="GREEN"; [ "$WORST" = 1 ] && grade="AMBER"; [ "$WORST" = 2 ] && grade="RED"
|
|
echo "------------------------------------------------------------"
|
|
echo " GRADE: $grade"
|
|
if [ "$WORST" -gt 0 ]; then echo " FIXES:"; printf '%b\n' "$FIXES"; fi
|
|
echo ""
|
|
return "$WORST"
|
|
}
|
|
|
|
# --- fleet audit -----------------------------------------------------------------------
|
|
# --all iterates every tenant in references/tenants.md, printing a per-tenant block +
|
|
# fixes, and exits with the WORST grade seen. A compact matrix mode is a follow-up.
|
|
if [ "$ALL" = 1 ]; then
|
|
tfile="$SKILL_DIR/references/tenants.md"
|
|
[ -f "$tfile" ] || { echo "[ERROR] tenants.md not found"; exit 3; }
|
|
worstall=0
|
|
while IFS='|' read -r _ disp dom guid rest; do
|
|
guid="$(echo "${guid:-}" | tr -d ' ')"
|
|
[[ "$guid" =~ ^[0-9a-fA-F-]{36}$ ]] || continue
|
|
audit_one "$guid"; rc=$?
|
|
[ "$rc" -gt "$worstall" ] && worstall=$rc
|
|
done < <(grep -E '^\|' "$tfile" | grep -vE 'Display Name|^\|\s*-')
|
|
exit "$worstall"
|
|
fi
|
|
|
|
[ -z "$TARGET" ] && { echo "[ERROR] give a tenant (domain|guid) or --all"; sed -n '9,13p' "$0"; exit 3; }
|
|
audit_one "$TARGET"; exit $?
|