Identity-Driven Architecture

Achieving Zero-Trust and Least-Privilege Data-Plane Access

In a Governance First architecture, connection strings, SAS tokens, and shared secrets are considered anti-patterns. They accumulate "Assumption Tax" and represent critical vulnerabilities. Instead, microservice communication must be strictly identity-driven, utilizing Microsoft Entra ID to broker communication exclusively on the Azure Data-Plane.

The Governance Toolkit

While tools like Terraform or Bicep are excellent for provisioning the Management Plane, they can become incredibly heavy and state-locked when assigning granular Data-Plane RBAC roles.

To solve this, Governance OS provides an elite-level toolkit: a bespoke, idempotent, bash-driven framework for surgical Azure RBAC assignments.

1. Event Grid: Publisher

01-eventgrid-publisher-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🌌 Event Grid RBAC Setup (Data-plane)
# -----------------------------------------------------------------------------
# Grants:
#  • Event Grid Data Sender role
#    to a principal (Function MI + optional current user)
#
# Notes:
#  • This uses standard Azure RBAC (az role assignment), unlike Cosmos SQL RBAC.
#  • The role "EventGrid Data Sender" allows pushing events to the Topic.
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
FUNC_APP="<Insert-Your-Function-Name>"            # <-- The PUBLISHER Function App (Account Service)
EG_TOPIC="<Insert-Your-Topic-Name>"            # <-- Your Event Grid Topic name
ROLE_NAME="EventGrid Data Sender"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Function App '${FUNC_APP}' exists"
az functionapp show -g "$RG" -n "$FUNC_APP" >/dev/null 2>&1 || die "Function App not found"
ok "Function App exists"

step "Checking that Event Grid Topic '${EG_TOPIC}' exists"
TOPIC_SCOPE=$(az eventgrid topic show -g "$RG" -n "$EG_TOPIC" --query id -o tsv 2>/dev/null || true)
[[ -z "$TOPIC_SCOPE" ]] && die "Event Grid Topic not found"
ok "Event Grid Topic exists. Scope resolved."

# ------------------------------- Helpers -------------------------------------
ensure_managed_identity () {
  local rg="$1" app="$2"
  echo "   ℹ️  Ensuring Managed Identity on '${app}'…" >&2
  az functionapp identity assign -g "$rg" -n "$app" >/dev/null 2>&1 || true
  local pid=""
  for i in {1..12}; do
    pid=$(az functionapp identity show -g "$rg" -n "$app" --query principalId -o tsv 2>/dev/null || true)
    if [[ -n "$pid" && "$pid" != "null" ]]; then
      echo "   ✅ Managed Identity principalId: $pid" >&2
      echo "$pid"   # <- ONLY the GUID to stdout
      return 0
    fi
    echo "   ℹ️  Waiting for principalId ($i/12)…" >&2
    sleep 2
  done
  echo "❌ principalId not available yet. Re-run shortly." >&2
  return 1
}

# Idempotent: create Azure RBAC role assignment if missing
ensure_azure_role_assignment () {
  local principal="$1" role="$2" scope="$3"
  local exist
  
  # Check if assignment exists
  exist=$(az role assignment list --assignee "$principal" --role "$role" --scope "$scope" --query "length([*])" -o tsv)
  
  if [[ "${exist}" == "0" ]]; then
    az role assignment create --assignee-object-id "$principal" --assignee-principal-type "ServicePrincipal" --role "$role" --scope "$scope" >/dev/null
    ok "Created Azure role assignment (principal: $principal, role: $role)"
  else
    info "Azure role assignment already exists for principal at this scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$FUNC_APP")
[[ "${GRANT_TO_ME}" == "true" ]] && ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) || ME_PRINCIPAL=""
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------------- Assign ------------------------------------
step "Ensuring Event Grid Data Sender role assignment(s)"
ensure_azure_role_assignment "$MI_PRINCIPAL" "$ROLE_NAME" "$TOPIC_SCOPE"

if [[ -n "$ME_PRINCIPAL" ]]; then
  # For users, principal-type is User, not ServicePrincipal. We override the helper slightly.
  exist_me=$(az role assignment list --assignee "$ME_PRINCIPAL" --role "$ROLE_NAME" --scope "$TOPIC_SCOPE" --query "length([*])" -o tsv)
  if [[ "${exist_me}" == "0" ]]; then
    az role assignment create --assignee-object-id "$ME_PRINCIPAL" --assignee-principal-type "User" --role "$ROLE_NAME" --scope "$TOPIC_SCOPE" >/dev/null
    ok "Created Azure role assignment for Current User"
  else
    info "Azure role assignment already exists for Current User"
  fi
fi

echo -e "\n✅ Event Grid RBAC complete."

2. Event Grid: Dead-Letter Queue

02-eventgrid-deadletter-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🌌 Event Grid Dead-Lettering RBAC Setup (Data-plane)
# -----------------------------------------------------------------------------
# Grants:
#  • Storage Blob Data Contributor role
#    to the Event Grid Topic's SAMI (and optional current user)
#
# Notes:
#  • This allows Event Grid to push failed events to a Storage Account.
#  • Granting to the current user allows you to view the dead-letter blobs 
#    directly in the Azure Portal without using SAS keys.
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
EG_TOPIC="<Insert-Your-Topic-Name>"            # <-- The Event Grid Topic (Source)
STORAGE_ACCOUNT="<Insert-Your-Account-Name>"      # <-- Your Storage Account (Destination)
ROLE_NAME="Storage Blob Data Contributor"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Event Grid Topic '${EG_TOPIC}' exists"
TOPIC_ID=$(az eventgrid topic show -g "$RG" -n "$EG_TOPIC" --query id -o tsv 2>/dev/null || true)
[[ -z "$TOPIC_ID" ]] && die "Event Grid Topic not found"
ok "Event Grid Topic exists"

step "Checking that Storage Account '${STORAGE_ACCOUNT}' exists"
STORAGE_SCOPE=$(az storage account show -g "$RG" -n "$STORAGE_ACCOUNT" --query id -o tsv 2>/dev/null || true)
[[ -z "$STORAGE_SCOPE" ]] && die "Storage Account not found"
ok "Storage Account exists. Scope resolved."

# ------------------------------- Helpers -------------------------------------
ensure_eg_managed_identity () {
  local rg="$1" topic="$2"
  echo "   ℹ️  Ensuring Managed Identity on Event Grid Topic '${topic}'…" >&2
  
  # Try to fetch existing identity
  local pid=$(az eventgrid topic show -g "$rg" -n "$topic" --query identity.principalId -o tsv 2>/dev/null || true)
  
  if [[ -z "$pid" || "$pid" == "null" ]]; then
    echo "   ℹ️  Identity not found. Enabling System Assigned Identity..." >&2
    az eventgrid topic update -g "$rg" -n "$topic" --identity systemassigned >/dev/null 2>&1 || true
    sleep 3
    pid=$(az eventgrid topic show -g "$rg" -n "$topic" --query identity.principalId -o tsv 2>/dev/null || true)
  fi

  if [[ -n "$pid" && "$pid" != "null" ]]; then
    echo "   ✅ Managed Identity principalId: $pid" >&2
    echo "$pid"   # <- ONLY the GUID to stdout
    return 0
  fi
  
  echo "❌ principalId not available. Ensure 'System Assigned' identity is enabled on the Topic." >&2
  return 1
}

# Idempotent: create Azure RBAC role assignment if missing
ensure_azure_role_assignment () {
  local principal="$1" role="$2" scope="$3"
  local exist
  
  # Check if assignment exists
  exist=$(az role assignment list --assignee "$principal" --role "$role" --scope "$scope" --query "length([*])" -o tsv)
  
  if [[ "${exist}" == "0" ]]; then
    az role assignment create --assignee-object-id "$principal" --assignee-principal-type "ServicePrincipal" --role "$role" --scope "$scope" >/dev/null
    ok "Created Azure role assignment (principal: $principal, role: $role)"
  else
    info "Azure role assignment already exists for principal at this scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_eg_managed_identity "$RG" "$EG_TOPIC")
[[ "${GRANT_TO_ME}" == "true" ]] && ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) || ME_PRINCIPAL=""
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------------- Assign ------------------------------------
step "Ensuring Storage Blob Data Contributor role assignment(s)"
ensure_azure_role_assignment "$MI_PRINCIPAL" "$ROLE_NAME" "$STORAGE_SCOPE"

if [[ -n "$ME_PRINCIPAL" ]]; then
  # For users, principal-type is User, not ServicePrincipal.
  exist_me=$(az role assignment list --assignee "$ME_PRINCIPAL" --role "$ROLE_NAME" --scope "$STORAGE_SCOPE" --query "length([*])" -o tsv)
  if [[ "${exist_me}" == "0" ]]; then
    az role assignment create --assignee-object-id "$ME_PRINCIPAL" --assignee-principal-type "User" --role "$ROLE_NAME" --scope "$STORAGE_SCOPE" >/dev/null
    ok "Created Azure role assignment for Current User"
  else
    info "Azure role assignment already exists for Current User"
  fi
fi

echo -e "\n✅ Dead-Lettering RBAC complete."

3. Cosmos DB: SQL Data Contributor

03-cosmos-appService-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🌌 Cosmos DB SQL RBAC Setup (Data-plane) for App Service
# -----------------------------------------------------------------------------
# Grants:
#  • Cosmos SQL RBAC role (e.g., "Cosmos DB Built-in Data Contributor")
#    to a principal (App Service MI + optional current user)
#
# Notes:
#  • This uses *Cosmos SQL* role assignments, not Azure RBAC.
#  • Role names here are Cosmos SQL built-ins; IDs are well-known:
#       - 00000000-0000-0000-0000-000000000001  Cosmos DB Built-in Data Reader
#       - 00000000-0000-0000-0000-000000000002  Cosmos DB Built-in Data Contributor
#       - 00000000-0000-0000-0000-000000000003  Cosmos DB Built-in Data Owner
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
APP_SERVICE="<Insert-Your-AppService-Name>"            # <-- your App Service name
COSMOS_ACC="<Insert-Your-Account-Name>"           # <-- your Cosmos account name
SQL_ROLE_NAME="Cosmos DB Built-in Data Contributor"
SQL_ROLE_FALLBACK_ID="00000000-0000-0000-0000-000000000002"
SQL_SCOPE="/"                               # "/" for whole account; or "/dbs/<db>/colls/<container>"

GRANT_TO_ME=true

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that App Service '${APP_SERVICE}' exists"
az webapp show -g "$RG" -n "$APP_SERVICE" >/dev/null 2>&1 || die "App Service not found"
ok "App Service exists"

step "Checking that Cosmos account '${COSMOS_ACC}' exists"
az cosmosdb show -g "$RG" -n "$COSMOS_ACC" >/dev/null 2>&1 || die "Cosmos account not found"
ok "Cosmos account exists"

# ------------------------------- Helpers -------------------------------------
ensure_managed_identity () {
  local rg="$1" app="$2"
  echo "   ℹ️  Ensuring Managed Identity on App Service '${app}'…" >&2
  az webapp identity assign -g "$rg" -n "$app" >/dev/null 2>&1 || true
  local pid=""
  for i in {1..12}; do
    pid=$(az webapp identity show -g "$rg" -n "$app" --query principalId -o tsv 2>/dev/null || true)
    if [[ -n "$pid" && "$pid" != "null" ]]; then
      echo "   ✅ Managed Identity principalId: $pid" >&2
      echo "$pid"   # <- ONLY the GUID to stdout
      return 0
    fi
    echo "   ℹ️  Waiting for principalId ($i/12)…" >&2
    sleep 2
  done
  echo "❌ principalId not available yet. Re-run shortly." >&2
  return 1
}

# Return the roleDefinitionId (prefer lookup by name, fallback to known ID)
resolve_sql_role_def_id () {
  local rg="$1" acc="$2" role_name="$3" fallback_id="$4"
  local rid
  rid=$(az cosmosdb sql role definition list --resource-group "$rg" --account-name "$acc" \
        --query "[?roleName=='${role_name}'].id" -o tsv 2>/dev/null || true)
  if [[ -n "$rid" ]]; then
    echo "$rid"
  else
    warn "Role '${role_name}' not returned by list; using fallback id '${fallback_id}'"
    echo "$fallback_id"
  fi
}

# Idempotent: create SQL role assignment if missing
ensure_sql_role_assignment () {
  local rg="$1" acc="$2" principal="$3" role_def_id="$4" scope="$5"
  # Check if assignment exists
  local exist
  exist=$(az cosmosdb sql role assignment list --resource-group "$rg" --account-name "$acc" \
          --query "[?principalId=='${principal}' && scope=='${scope}' && roleDefinitionId=='${role_def_id}'] | length(@)" -o tsv)
  if [[ "${exist}" == "0" ]]; then
    az cosmosdb sql role assignment create \
      --resource-group "$rg" --account-name "$acc" \
      --scope "$scope" --principal-id "$principal" --role-definition-id "$role_def_id" >/dev/null
    ok "Created Cosmos SQL role assignment (principal: $principal, role: $role_def_id, scope: $scope)"
  else
    info "Cosmos SQL role assignment already exists for principal at scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$APP_SERVICE")
[[ "${GRANT_TO_ME}" == "true" ]] && ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) || ME_PRINCIPAL=""
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------- Role definition resolve -------------------------
step "Resolving Cosmos SQL role definition id for '${SQL_ROLE_NAME}'"
ROLE_DEF_ID=$(resolve_sql_role_def_id "$RG" "$COSMOS_ACC" "$SQL_ROLE_NAME" "$SQL_ROLE_FALLBACK_ID")
ok "Using roleDefinitionId: $ROLE_DEF_ID"

# --------------------------------- Assign ------------------------------------
step "Ensuring Cosmos SQL role assignment(s)"
ensure_sql_role_assignment "$RG" "$COSMOS_ACC" "$MI_PRINCIPAL" "$ROLE_DEF_ID" "$SQL_SCOPE"
[[ -n "$ME_PRINCIPAL" ]] && ensure_sql_role_assignment "$RG" "$COSMOS_ACC" "$ME_PRINCIPAL" "$ROLE_DEF_ID" "$SQL_SCOPE"

echo -e "\n✅ Cosmos SQL RBAC for App Service complete."

3. Cosmos DB: SQL Data Contributor

04-cosmos-function-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🌌 Cosmos DB SQL RBAC Setup (Data-plane)
# -----------------------------------------------------------------------------
# Grants:
#  • Cosmos SQL RBAC role (e.g., "Cosmos DB Built-in Data Contributor")
#    to a principal (Function MI + optional current user)
#
# Notes:
#  • This uses *Cosmos SQL* role assignments, not Azure RBAC.
#  • Role names here are Cosmos SQL built-ins; IDs are well-known:
#       - 00000000-0000-0000-0000-000000000001  Cosmos DB Built-in Data Reader
#       - 00000000-0000-0000-0000-000000000002  Cosmos DB Built-in Data Contributor
#       - 00000000-0000-0000-0000-000000000003  Cosmos DB Built-in Data Owner
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
FUNC_APP="<Insert-Your-Function-Name>"
COSMOS_ACC="<Insert-Your-Account-Name>"            # <-- your account name
SQL_ROLE_NAME="Cosmos DB Built-in Data Contributor"
SQL_ROLE_FALLBACK_ID="00000000-0000-0000-0000-000000000002"
SQL_SCOPE="/"                               # "/" for whole account; or "/dbs/<db>/colls/<container>"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# Also grant roles to this specific user (by email / UPN). Leave empty to skip.
GRANT_TO_EMAIL="your.name@company.com"

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Function App '${FUNC_APP}' exists"
az functionapp show -g "$RG" -n "$FUNC_APP" >/dev/null 2>&1 || die "Function App not found"
ok "Function App exists"

step "Checking that Cosmos account '${COSMOS_ACC}' exists"
az cosmosdb show -g "$RG" -n "$COSMOS_ACC" >/dev/null 2>&1 || die "Cosmos account not found"
ok "Cosmos account exists"

# ------------------------------- Helpers -------------------------------------
ensure_managed_identity () {
  local rg="$1" app="$2"
  echo "   ℹ️  Ensuring Managed Identity on '${app}'…" >&2
  az functionapp identity assign -g "$rg" -n "$app" >/dev/null 2>&1 || true
  local pid=""
  for i in {1..12}; do
    pid=$(az functionapp identity show -g "$rg" -n "$app" --query principalId -o tsv 2>/dev/null || true)
    if [[ -n "$pid" && "$pid" != "null" ]]; then
      echo "   ✅ Managed Identity principalId: $pid" >&2
      echo "$pid"   # <- ONLY the GUID to stdout
      return 0
    fi
    echo "   ℹ️  Waiting for principalId ($i/12)…" >&2
    sleep 2
  done
  echo "❌ principalId not available yet. Re-run shortly." >&2
  return 1
}

# Return the roleDefinitionId (prefer lookup by name, fallback to known ID)
resolve_sql_role_def_id () {
  local rg="$1" acc="$2" role_name="$3" fallback_id="$4"
  local rid
  rid=$(az cosmosdb sql role definition list --resource-group "$rg" --account-name "$acc" \
        --query "[?roleName=='${role_name}'].id" -o tsv 2>/dev/null || true)
  if [[ -n "$rid" ]]; then
    echo "$rid"
  else
    warn "Role '${role_name}' not returned by list; using fallback id '${fallback_id}'"
    echo "$fallback_id"
  fi
}

# Idempotent: create SQL role assignment if missing
ensure_sql_role_assignment () {
  local rg="$1" acc="$2" principal="$3" role_def_id="$4" scope="$5"
  # Check if assignment exists
  local exist
  exist=$(az cosmosdb sql role assignment list --resource-group "$rg" --account-name "$acc" \
          --query "[?principalId=='${principal}' && scope=='${scope}' && roleDefinitionId=='${role_def_id}'] | length(@)" -o tsv)
  if [[ "${exist}" == "0" ]]; then
    az cosmosdb sql role assignment create \
      --resource-group "$rg" --account-name "$acc" \
      --scope "$scope" --principal-id "$principal" --role-definition-id "$role_def_id" >/dev/null
    ok "Created Cosmos SQL role assignment (principal: $principal, role: $role_def_id, scope: $scope)"
  else
    info "Cosmos SQL role assignment already exists for principal at scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$FUNC_APP")
[[ "${GRANT_TO_ME}" == "true" ]] && ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) || ME_PRINCIPAL=""
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------- Role definition resolve --------------------------
step "Resolving Cosmos SQL role definition id for '${SQL_ROLE_NAME}'"
ROLE_DEF_ID=$(resolve_sql_role_def_id "$RG" "$COSMOS_ACC" "$SQL_ROLE_NAME" "$SQL_ROLE_FALLBACK_ID")
ok "Using roleDefinitionId: $ROLE_DEF_ID"

# --------------------------------- Assign ------------------------------------
step "Ensuring Cosmos SQL role assignment(s)"
ensure_sql_role_assignment "$RG" "$COSMOS_ACC" "$MI_PRINCIPAL" "$ROLE_DEF_ID" "$SQL_SCOPE"
[[ -n "$ME_PRINCIPAL" ]] && ensure_sql_role_assignment "$RG" "$COSMOS_ACC" "$ME_PRINCIPAL" "$ROLE_DEF_ID" "$SQL_SCOPE"

echo -e "\n✅ Cosmos SQL RBAC complete."

3. Key Vault: Ensure Secrets Exists

05-keyvault-ensure-secrets.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🧾 Key Vault Secrets Ensure (Create/Update)
# -----------------------------------------------------------------------------
# Behavior:
#  • Upserts a set of secrets into a Key Vault (idempotent)
#  • Compares current value and only sets when different
#
# Note: No MI required unless you're authenticating via MI. This script assumes
# your current az login has permissions to set secrets in the vault.
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

STEP=0; TOTAL_STEPS=3
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
KV_NAME="<Insert-Your-KeyVault-Name>"

# Parallel arrays so this works on older bash too (macOS default)
SECRET_NAMES=( "ExampleApiKey" "AnotherSetting" )
SECRET_VALUES=( "change-me"    "value-2" )

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"; ok "Azure CLI is logged in"

step "Checking that Key Vault '${KV_NAME}' exists"
az keyvault show -g "$RG" -n "$KV_NAME" >/dev/null 2>&1 || die "Key Vault not found"; ok "Key Vault exists"

# ------------------------------- Validation ----------------------------------
if [[ ${#SECRET_NAMES[@]} -ne ${#SECRET_VALUES[@]} ]]; then
  die "SECRET_NAMES and SECRET_VALUES length mismatch"
fi

# ------------------------------- Upserts -------------------------------------
step "Ensuring secrets in Key Vault"
for (( i=0; i<${#SECRET_NAMES[@]}; i++ )); do
  NAME="${SECRET_NAMES[$i]}"
  VALUE="${SECRET_VALUES[$i]}"
  CURRENT=$(az keyvault secret show --vault-name "$KV_NAME" -n "$NAME" --query value -o tsv 2>/dev/null || true)
  if [[ "$CURRENT" != "$VALUE" ]]; then
    az keyvault secret set --vault-name "$KV_NAME" -n "$NAME" --value "$VALUE" >/dev/null
    ok "Secret set/updated: $NAME"
  else
    info "Secret up-to-date: $NAME"
  fi
done

echo -e "\n✅ Key Vault secrets ensured."

3. Key Vault: Secrets User

06-keyvault-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🔐 Key Vault RBAC Setup (RBAC-only, read-only)
# -----------------------------------------------------------------------------
# Grants:
#   • Key Vault Secrets User (read-only: get/list) to:
#       - Function App's System-Assigned Managed Identity
#       - (optional) current signed-in user
#
# Requirements:
#   • Key Vault must have RBAC enabled (enableRbacAuthorization = true)
#     Enable with: az keyvault update -g <rg> -n <kv> --enable-rbac-authorization true
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){   echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){  echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
FUNC_APP="<Insert-Your-Function-Name>"
KV_NAME="<Insert-Your-KeyVault-Name>"

# RBAC role to grant (read-only secrets)
KV_RBAC_ROLE="Key Vault Secrets User"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# Also grant roles to this specific user (by email / UPN). Leave empty to skip.
GRANT_TO_EMAIL="your.name@company.com"

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Function App '${FUNC_APP}' exists"
az functionapp show -g "$RG" -n "$FUNC_APP" >/dev/null 2>&1 || die "Function App not found"
ok "Function App exists"

step "Checking that Key Vault '${KV_NAME}' exists and uses RBAC"
az keyvault show -g "$RG" -n "$KV_NAME" >/dev/null 2>&1 || die "Key Vault not found"
IS_RBAC=$(az keyvault show -g "$RG" -n "$KV_NAME" --query "properties.enableRbacAuthorization" -o tsv)
if [[ "$IS_RBAC" != "true" ]]; then
  die "Vault is not using RBAC. Enable it with:
  az keyvault update -g \"$RG\" -n \"$KV_NAME\" --enable-rbac-authorization true"
fi
ok "Key Vault is using Azure RBAC"

# ------------------------------- Helpers -------------------------------------
# Enable MI (idempotent) and print only the principalId to stdout
ensure_managed_identity () {
  local rg="$1" app="$2"
  echo "   ℹ️  Ensuring Managed Identity on '${app}'…" >&2
  az functionapp identity assign -g "$rg" -n "$app" >/dev/null 2>&1 || true
  local pid=""
  for i in {1..12}; do
    pid=$(az functionapp identity show -g "$rg" -n "$app" --query principalId -o tsv 2>/dev/null || true)
    if [[ -n "$pid" && "$pid" != "null" ]]; then
      echo "   ✅ Managed Identity principalId: $pid" >&2
      echo "$pid"
      return 0
    fi
    echo "   ℹ️  Waiting for principalId ($i/12)…" >&2
    sleep 2
  done
  die "principalId not available yet. Re-run shortly."
}

grant_rbac_role () {
  local principal="$1" role="$2" scope="$3"
  local exist
  exist=$(az role assignment list --assignee "$principal" --scope "$scope" \
            --query "[?roleDefinitionName=='$role'] | length(@)" -o tsv 2>/dev/null || echo "0")
  if [[ "$exist" -eq 0 ]]; then
    if az role assignment create --assignee "$principal" --role "$role" --scope "$scope" >/dev/null 2>&1; then
      ok "Assigned RBAC role '$role' on $scope"
    else
      warn "Failed to assign RBAC role '$role' on $scope (check permissions)"
    fi
  else
    info "RBAC already present: $role on $scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$FUNC_APP")
if [[ "${GRANT_TO_ME}" == "true" ]]; then
  ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)
else
  ME_PRINCIPAL=""
fi
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------------- RBAC --------------------------------------
step "Granting Key Vault RBAC role: ${KV_RBAC_ROLE}"
KV_SCOPE=$(az keyvault show -g "$RG" -n "$KV_NAME" --query id -o tsv)
grant_rbac_role "$MI_PRINCIPAL" "$KV_RBAC_ROLE" "$KV_SCOPE"
[[ -n "$ME_PRINCIPAL" ]] && grant_rbac_role "$ME_PRINCIPAL" "$KV_RBAC_ROLE" "$KV_SCOPE"

# ------------------------------- Validation ----------------------------------
step "Validating assignments on the vault"
az role assignment list --scope "$KV_SCOPE" \
  --query "[?principalId=='$MI_PRINCIPAL' || principalId=='$ME_PRINCIPAL'].[principalId,roleDefinitionName]" -o table

echo -e "\n✅ Key Vault RBAC (read-only secrets) complete."

3. Service Bus: Topic & Subscription Creation

07-serviceBus-topics-subscriptions-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🛰 Cosmos Connector – Service Bus RBAC Setup (Senders + Receivers)
# -----------------------------------------------------------------------------
# Grants:
#   • Azure Service Bus Data Receiver on specified topic/subscriptions
#   • Azure Service Bus Data Sender on specified topics
#
# Skips cleanly when any list is empty.
# Creates missing subscriptions automatically.
# -----------------------------------------------------------------------------

set -euo pipefail

# Keep az non-interactive (avoid extension install prompts)
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0
TOTAL_STEPS=6  # preflight(3) + principals(1) + receivers(1) + senders(1)
step()   { STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok()     { echo "   ✅ $*"; }
info()   { echo "   ℹ️  $*"; }
warn()   { echo "   ⚠️  $*"; }
fail()   { echo "   ⛔ $*"; }

safe_exit() {
  echo ""
  echo "❌ FATAL ERROR: $1"
  echo "🛑 Stopping remainder of script due to unrecoverable error."
  exit 1
}

# -----------------------------------------------------------------------------
# 🧭 Configuration
# -----------------------------------------------------------------------------
RG="<Insert-Your-Resource-Group-Name>"
NS="<Insert-Your-ServiceBus-Namespace>"
FUNC_APP="<Insert-Your-Function-Name>"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# Also grant roles to this specific user (by email / UPN). Leave empty to skip.
GRANT_TO_EMAIL="your.name@company.com"

# Receivers: 1:1 alignment between topics and subs
RECEIVE_TOPICS=(
  "eventdetails-changed"
  "eventsessions-changed"
  "eventlearners-changed"
)
RECEIVE_SUBS=(
  "mtcosmos-events-subscription"
  "mtcosmos-events-subscription"
  "mtcosmos-events-subscription"
)

# Senders: topics you publish to (leave empty to skip)
SEND_TOPICS=()

# -----------------------------------------------------------------------------
# 🧩 Pre-flight checks
# -----------------------------------------------------------------------------
step "Verifying Azure CLI context"
if az account show >/dev/null 2>&1; then
  ok "Azure CLI is logged in"
else
  fail "Azure CLI not logged in or context unavailable"
  safe_exit "Run 'az login' and re-run the script."
fi

step "Checking that Resource Group '${RG}' and Namespace '${NS}' exist"
if timeout 25 az servicebus namespace show -g "$RG" -n "$NS" >/dev/null 2>./.az-ns.err; then
  ok "Service Bus Namespace '$NS' exists in RG '$RG'"
else
  fail "Namespace '$NS' not found or CLI stalled"
  sed -n '1,120p' ./.az-ns.err || true
  safe_exit "Ensure RG and Namespace names are correct."
fi
rm -f ./.az-ns.err

step "Checking that Function App '${FUNC_APP}' exists"
if timeout 25 az functionapp show -g "$RG" -n "$FUNC_APP" >/dev/null 2>./.az-func.err; then
  ok "Function App '$FUNC_APP' exists"
else
  fail "Function App '$FUNC_APP' not found or CLI stalled"
  sed -n '1,120p' ./.az-func.err || true
  safe_exit "Verify Function App name and resource group."
fi
rm -f ./.az-func.err

# -----------------------------------------------------------------------------
# ⚙️ Helpers
# -----------------------------------------------------------------------------
# Ensure system-assigned Managed Identity is enabled and return its principalId.
ensure_managed_identity () {
  local RG="$1" APP="$2"

  >&2 echo "   ℹ️  Ensuring Managed Identity on '$APP'…"

  if ! timeout 30 az functionapp identity assign -g "$RG" -n "$APP" >/dev/null 2>&1; then
    >&2 echo "   ⚠️  Identity assign returned warning (might already exist)"
  fi

  local ATTEMPTS=0
  while [[ $ATTEMPTS -lt 12 ]]; do
    local PRINCIPAL=$(az functionapp identity show -g "$RG" -n "$APP" --query principalId -o tsv 2>/dev/null || true)

    if [[ -n "$PRINCIPAL" && "$PRINCIPAL" != "null" ]]; then
      >&2 echo "   ✅ Managed Identity principalId: $PRINCIPAL"
      echo "$PRINCIPAL"   # ONLY THIS GOES TO STDOUT
      return 0
    fi

    ATTEMPTS=$((ATTEMPTS+1))
    sleep 3
  done

  safe_exit "Managed Identity principalId not available yet."
}

# Strict role assignment for the Function App MI (MI failure = fatal)
grant_assignment_strict () {
  local PRINCIPAL="$1" ROLE="$2" SCOPE="$3"

  local EXIST
  EXIST=$(az role assignment list \
            --assignee "$PRINCIPAL" \
            --scope "$SCOPE" \
            --query "[?roleDefinitionName=='$ROLE'] | length(@)" \
            -o tsv 2>/dev/null || echo "0")

  if [[ "$EXIST" -eq 0 ]]; then
    info "No existing '$ROLE' assignment on $SCOPE for MI $PRINCIPAL. Creating…"
    if ! az role assignment create \
          --assignee "$PRINCIPAL" \
          --role "$ROLE" \
          --scope "$SCOPE" >/dev/null 2>./.az-role-create.err; then
      warn "Error from az while assigning $ROLE on $SCOPE for MI $PRINCIPAL:"
      sed -n '1,120p' ./.az-role-create.err || true
      rm -f ./.az-role-create.err
      safe_exit "Failed to assign '$ROLE' to Function App managed identity. You probably need Owner or User Access Administrator on this scope."
    fi
    rm -f ./.az-role-create.err
    ok "Assigned $ROLE on $SCOPE for MI $PRINCIPAL"
  else
    info "RBAC already present: $ROLE on $SCOPE for MI $PRINCIPAL"
  fi
}

# Soft role assignment for your user (failure = warning only)
grant_assignment_soft () {
  local PRINCIPAL="$1" ROLE="$2" SCOPE="$3"

  local EXIST
  EXIST=$(az role assignment list \
            --assignee "$PRINCIPAL" \
            --scope "$SCOPE" \
            --query "[?roleDefinitionName=='$ROLE'] | length(@)" \
            -o tsv 2>/dev/null || echo "0")

  if [[ "$EXIST" -eq 0 ]]; then
    info "No existing '$ROLE' assignment on $SCOPE for user $PRINCIPAL. Creating…"
    if ! az role assignment create \
          --assignee "$PRINCIPAL" \
          --role "$ROLE" \
          --scope "$SCOPE" >/dev/null 2>./.az-role-create-me.err; then
      warn "Failed to assign $ROLE on $SCOPE for user $PRINCIPAL (dev convenience only). Raw error:"
      sed -n '1,120p' ./.az-role-create-me.err || true
      rm -f ./.az-role-create-me.err
      return 0
    fi
    rm -f ./.az-role-create-me.err
    ok "Assigned $ROLE on $SCOPE for user $PRINCIPAL"
  else
    info "RBAC already present: $ROLE on $SCOPE for user $PRINCIPAL"
  fi
}

grant_receiver () {
  local SCOPE="$1"

  # First: Function App MI (strict)
  grant_assignment_strict "$MI_PRINCIPAL" "Azure Service Bus Data Receiver" "$SCOPE"

  # Then: current user (optional, soft)
  if [[ -n "${ME_PRINCIPAL:-}" ]]; then
    grant_assignment_soft "$ME_PRINCIPAL" "Azure Service Bus Data Receiver" "$SCOPE"
  fi
}

grant_sender () {
  local SCOPE="$1"

  grant_assignment_strict "$MI_PRINCIPAL" "Azure Service Bus Data Sender" "$SCOPE"

  if [[ -n "${ME_PRINCIPAL:-}" ]]; then
    grant_assignment_soft "$ME_PRINCIPAL" "Azure Service Bus Data Sender" "$SCOPE"
  fi
}

# -----------------------------------------------------------------------------
# 👤 Principals (MI + optional current user)
# -----------------------------------------------------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$FUNC_APP")

if [[ "${GRANT_TO_ME}" == "true" ]]; then
  ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)
else
  ME_PRINCIPAL=""
fi

info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# -----------------------------------------------------------------------------
# 📥 Receivers (topic/subscription)
# -----------------------------------------------------------------------------
step "Configuring RECEIVER permissions"
if [[ "${#RECEIVE_TOPICS[@]}" -gt 0 ]]; then
  if [[ "${#RECEIVE_TOPICS[@]}" -ne "${#RECEIVE_SUBS[@]}" ]]; then
    safe_exit "RECEIVE_TOPICS and RECEIVE_SUBS arrays are misaligned (length mismatch)."
  fi

  for i in "${!RECEIVE_TOPICS[@]}"; do
    T="${RECEIVE_TOPICS[$i]}"
    S="${RECEIVE_SUBS[$i]}"

    echo ""
    echo "🔸 Topic: $T"
    echo "   Subscription: $S"

    # Ensure subscription exists (if not, create it)
    if ! az servicebus topic subscription show -g "$RG" --namespace-name "$NS" --topic-name "$T" -n "$S" >/dev/null 2>./.az-sub-show.err; then
      info "Creating missing subscription…"

      if ! az servicebus topic subscription create -g "$RG" --namespace-name "$NS" --topic-name "$T" -n "$S" >/dev/null 2>./.az-sub-create.err; then
        fail "Failed to create subscription '$S' on topic '$T'. Raw error:"
        sed -n '1,80p' ./.az-sub-create.err || true
        safe_exit "Failed to create subscription '$S' on topic '$T'."
      fi

      ok "Created $S on $T"
    else
      info "Subscription already exists"
    fi

    rm -f ./.az-sub-show.err ./.az-sub-create.err

    SUB_ID=$(az servicebus topic subscription show -g "$RG" --namespace-name "$NS" \
              --topic-name "$T" -n "$S" --query id -o tsv 2>/dev/null || true)
    [[ -z "$SUB_ID" ]] && safe_exit "Failed to retrieve subscription ID for '$S' on '$T'."

    grant_receiver "$SUB_ID"
  done
else
  info "No RECEIVER mappings provided; skipping receiver grants"
fi

# -----------------------------------------------------------------------------
# 📤 Senders (topic-only)
# -----------------------------------------------------------------------------
step "Configuring SENDER permissions"
if [[ "${#SEND_TOPICS[@]}" -gt 0 ]]; then
  for T in "${SEND_TOPICS[@]}"; do
    echo ""
    echo "🔸 Topic: $T"

    # Ensure topic exists (if not, create it)
    if ! az servicebus topic show -g "$RG" --namespace-name "$NS" -n "$T" >/dev/null 2>./.az-topic-show.err; then
      info "Creating missing topic…"
      if ! az servicebus topic create -g "$RG" --namespace-name "$NS" -n "$T" >/dev/null 2>./.az-topic-create.err; then
        fail "Failed to create topic '$T'. Raw error:"
        sed -n '1,80p' ./.az-topic-create.err || true
        safe_exit "Failed to create topic '$T'."
      fi
      ok "Created topic $T"
    else
      info "Topic already exists"
    fi
    rm -f ./.az-topic-show.err ./.az-topic-create.err

    TOPIC_ID=$(az servicebus topic show -g "$RG" --namespace-name "$NS" -n "$T" --query id -o tsv 2>/dev/null || true)
    [[ -z "$TOPIC_ID" ]] && safe_exit "Failed to retrieve topic ID for '$T'."

    grant_sender "$TOPIC_ID"
  done
else
  info "No SENDER topics provided; skipping sender grants"
fi

# -----------------------------------------------------------------------------
# ✅ Summary
# -----------------------------------------------------------------------------
echo ""
ok "RBAC assignments completed."
info "Restart your Function App (or func host) to refresh AAD tokens."
echo "---------------------------------------------------------------"

3. Table Storage: Data Contributor

08-tableStorage-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 📦 Azure Storage Tables RBAC Setup
# -----------------------------------------------------------------------------
# Grants:
#   • "Storage Table Data Contributor" on a Storage Account scope to:
#       - Function App's System-Assigned Managed Identity
#       - (optional) current signed-in user
#
# Notes:
#   • This is data-plane RBAC for Table Storage (works with TableServiceClient + AAD).
#   • Your app can use RBAC via endpoint (https://<account>.table.core.windows.net).
#   • If RBAC isn't available in some env, your app can fallback to a connection string.
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){   echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){  echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
FUNC_APP="<Insert-Your-Function-Name>"
STG_ACCOUNT="<Insert-Your-Account-Name>"     # <-- set your storage account name

# Role to grant (read/write table entities)
STG_TABLE_ROLE="Storage Table Data Contributor"
# For read-only, you could use:
# STG_TABLE_ROLE="Storage Table Data Reader"

GRANT_TO_ME=true

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Function App '${FUNC_APP}' exists"
az functionapp show -g "$RG" -n "$FUNC_APP" >/dev/null 2>&1 || die "Function App not found"
ok "Function App exists"

step "Checking that Storage Account '${STG_ACCOUNT}' exists"
az storage account show -g "$RG" -n "$STG_ACCOUNT" >/dev/null 2>&1 || die "Storage Account not found"
ok "Storage Account exists"

# ------------------------------- Helpers -------------------------------------
ensure_managed_identity () {
  local rg="$1" app="$2"
  echo "   ℹ️  Ensuring Managed Identity on '${app}'…" >&2
  az functionapp identity assign -g "$rg" -n "$app" >/dev/null 2>&1 || true
  local pid=""
  for i in {1..12}; do
    pid=$(az functionapp identity show -g "$rg" -n "$app" --query principalId -o tsv 2>/dev/null || true)
    if [[ -n "$pid" && "$pid" != "null" ]]; then
      echo "   ✅ Managed Identity principalId: $pid" >&2
      echo "$pid"
      return 0
    fi
    echo "   ℹ️  Waiting for principalId ($i/12)…" >&2
    sleep 2
  done
  die "principalId not available yet. Re-run shortly."
}

grant_rbac_role () {
  local principal="$1" role="$2" scope="$3"
  local exist
  exist=$(az role assignment list --assignee "$principal" --scope "$scope" \
            --query "[?roleDefinitionName=='$role'] | length(@)" -o tsv 2>/dev/null || echo "0")
  if [[ "$exist" -eq 0 ]]; then
    if az role assignment create --assignee "$principal" --role "$role" --scope "$scope" >/dev/null 2>&1; then
      ok "Assigned RBAC role '$role' on $scope"
    else
      warn "Failed to assign RBAC role '$role' on $scope (check permissions)"
    fi
  else
    info "RBAC already present: $role on $scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_managed_identity "$RG" "$FUNC_APP")
if [[ "${GRANT_TO_ME}" == "true" ]]; then
  ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)
else
  ME_PRINCIPAL=""
fi
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------------- RBAC --------------------------------------
step "Granting Storage Tables RBAC role: ${STG_TABLE_ROLE}"
STG_SCOPE=$(az storage account show -g "$RG" -n "$STG_ACCOUNT" --query id -o tsv)
grant_rbac_role "$MI_PRINCIPAL" "$STG_TABLE_ROLE" "$STG_SCOPE"
[[ -n "$ME_PRINCIPAL" ]] && grant_rbac_role "$ME_PRINCIPAL" "$STG_TABLE_ROLE" "$STG_SCOPE"

# ------------------------------- Validation ----------------------------------
step "Validating assignments on the storage account"
az role assignment list --scope "$STG_SCOPE" \
  --query "[?principalId=='$MI_PRINCIPAL' || principalId=='$ME_PRINCIPAL'].[principalId,roleDefinitionName]" -o table

echo -e "\n✅ Storage Tables RBAC complete."

3. Event Grid: Subscriptions Setup

09-eventGrid-deadletter-rbac.sh
#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# 🌌 Event Grid Dead-Lettering RBAC Setup (Data-plane)
# -----------------------------------------------------------------------------
# Grants:
#  • Storage Blob Data Contributor role
#    to the Event Grid Topic's SAMI (and optional current user)
#
# Notes:
#  • This allows Event Grid to push failed events to a Storage Account.
#  • Granting to the current user allows you to view the dead-letter blobs 
#    directly in the Azure Portal without using SAS keys.
# -----------------------------------------------------------------------------

set -euo pipefail
az config set extension.use_dynamic_install=yes_without_prompt >/dev/null

# ----------------------------- Progress helpers ------------------------------
STEP=0; TOTAL_STEPS=6
step(){ STEP=$((STEP+1)); echo -e "\n🔶 [${STEP}/${TOTAL_STEPS}] $*"; }
ok(){ echo "   ✅ $*"; }
info(){ echo "   ℹ️  $*"; }
warn(){ echo "   ⚠️  $*"; }
die(){ echo -e "\n❌ $*\n"; exit 1; }

# ------------------------------- Configuration -------------------------------
RG="<Insert-Your-Resource-Group-Name>"
EG_TOPIC="<Insert-Your-Topic-Name>"            # <-- The Event Grid Topic (Source)
STORAGE_ACCOUNT="<Insert-Your-Account-Name>"      # <-- Your Storage Account (Destination)
ROLE_NAME="Storage Blob Data Contributor"

# Toggle whether to also grant roles to the signed-in user (local dev convenience)
GRANT_TO_ME=true

# ------------------------------ Pre-flight -----------------------------------
step "Verifying Azure CLI context"
az account show >/dev/null 2>&1 || die "Not logged in. Run: az login"
ok "Azure CLI is logged in"

step "Checking that Event Grid Topic '${EG_TOPIC}' exists"
TOPIC_ID=$(az eventgrid topic show -g "$RG" -n "$EG_TOPIC" --query id -o tsv 2>/dev/null || true)
[[ -z "$TOPIC_ID" ]] && die "Event Grid Topic not found"
ok "Event Grid Topic exists"

step "Checking that Storage Account '${STORAGE_ACCOUNT}' exists"
STORAGE_SCOPE=$(az storage account show -g "$RG" -n "$STORAGE_ACCOUNT" --query id -o tsv 2>/dev/null || true)
[[ -z "$STORAGE_SCOPE" ]] && die "Storage Account not found"
ok "Storage Account exists. Scope resolved."

# ------------------------------- Helpers -------------------------------------
ensure_eg_managed_identity () {
  local rg="$1" topic="$2"
  echo "   ℹ️  Ensuring Managed Identity on Event Grid Topic '${topic}'…" >&2
  
  # Try to fetch existing identity
  local pid=$(az eventgrid topic show -g "$rg" -n "$topic" --query identity.principalId -o tsv 2>/dev/null || true)
  
  if [[ -z "$pid" || "$pid" == "null" ]]; then
    echo "   ℹ️  Identity not found. Enabling System Assigned Identity..." >&2
    az eventgrid topic update -g "$rg" -n "$topic" --identity systemassigned >/dev/null 2>&1 || true
    sleep 3
    pid=$(az eventgrid topic show -g "$rg" -n "$topic" --query identity.principalId -o tsv 2>/dev/null || true)
  fi

  if [[ -n "$pid" && "$pid" != "null" ]]; then
    echo "   ✅ Managed Identity principalId: $pid" >&2
    echo "$pid"   # <- ONLY the GUID to stdout
    return 0
  fi
  
  echo "❌ principalId not available. Ensure 'System Assigned' identity is enabled on the Topic." >&2
  return 1
}

# Idempotent: create Azure RBAC role assignment if missing
ensure_azure_role_assignment () {
  local principal="$1" role="$2" scope="$3"
  local exist
  
  # Check if assignment exists
  exist=$(az role assignment list --assignee "$principal" --role "$role" --scope "$scope" --query "length([*])" -o tsv)
  
  if [[ "${exist}" == "0" ]]; then
    az role assignment create --assignee-object-id "$principal" --assignee-principal-type "ServicePrincipal" --role "$role" --scope "$scope" >/dev/null
    ok "Created Azure role assignment (principal: $principal, role: $role)"
  else
    info "Azure role assignment already exists for principal at this scope"
  fi
}

# ------------------------------- Principals ----------------------------------
step "Ensuring Managed Identity and resolving principals"
MI_PRINCIPAL=$(ensure_eg_managed_identity "$RG" "$EG_TOPIC")
[[ "${GRANT_TO_ME}" == "true" ]] && ME_PRINCIPAL=$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true) || ME_PRINCIPAL=""
info "Managed Identity: $MI_PRINCIPAL"
[[ -n "$ME_PRINCIPAL" ]] && info "Current User:      $ME_PRINCIPAL" || info "Current User:      (skipped)"

# --------------------------------- Assign ------------------------------------
step "Ensuring Storage Blob Data Contributor role assignment(s)"
ensure_azure_role_assignment "$MI_PRINCIPAL" "$ROLE_NAME" "$STORAGE_SCOPE"

if [[ -n "$ME_PRINCIPAL" ]]; then
  # For users, principal-type is User, not ServicePrincipal.
  exist_me=$(az role assignment list --assignee "$ME_PRINCIPAL" --role "$ROLE_NAME" --scope "$STORAGE_SCOPE" --query "length([*])" -o tsv)
  if [[ "${exist_me}" == "0" ]]; then
    az role assignment create --assignee-object-id "$ME_PRINCIPAL" --assignee-principal-type "User" --role "$ROLE_NAME" --scope "$STORAGE_SCOPE" >/dev/null
    ok "Created Azure role assignment for Current User"
  else
    info "Azure role assignment already exists for Current User"
  fi
fi

echo -e "\n✅ Dead-Lettering RBAC complete."