|
| 1 | +#!/usr/bin/env bash |
| 2 | +# shellcheck shell=bash |
| 3 | +set -euo pipefail |
| 4 | + |
| 5 | +readonly DEFAULT_LOCATION="norwayeast" |
| 6 | +readonly PLACEHOLDER_VALUE="REPLACE_ME" |
| 7 | +readonly RBAC_DEPLOY_ACTION="Microsoft.Resources/deployments/write" |
| 8 | +readonly REQUIRED_DIR_ROLE="Application Developer" |
| 9 | +readonly ARM_API_VERSION="2022-04-01" |
| 10 | + |
| 11 | +: "${LOCATION:=$DEFAULT_LOCATION}" |
| 12 | + |
| 13 | +die() { printf 'ERROR (%s): %s\n' "${FUNCNAME[1]:-_lib}" "$*" >&2; exit 1; } |
| 14 | +warn() { printf 'WARN (%s): %s\n' "${FUNCNAME[1]:-_lib}" "$*" >&2; } |
| 15 | +info() { printf '[%s] %s\n' "${FUNCNAME[1]:-_lib}" "$*"; } |
| 16 | + |
| 17 | +require_config() { |
| 18 | + local var=$1 val=${!1:-} |
| 19 | + [[ -n "$val" && "$val" != "$PLACEHOLDER_VALUE" ]] \ |
| 20 | + || die "$var is not configured. Edit .mise/config.toml." |
| 21 | +} |
| 22 | + |
| 23 | +ensure_login() { |
| 24 | + require_config AZURE_SUBSCRIPTION |
| 25 | + az account show >/dev/null 2>&1 || az login >/dev/null |
| 26 | + require_subscription |
| 27 | + require_active_subscription_role |
| 28 | +} |
| 29 | + |
| 30 | +init_task() { |
| 31 | + export ENVIRONMENT="${1:?environment required (dev|test|prod)}" |
| 32 | + require_environment |
| 33 | + ensure_login |
| 34 | + require_config APPLICATION_NAME |
| 35 | +} |
| 36 | + |
| 37 | +require_subscription() { |
| 38 | + local active_id active_name |
| 39 | + active_id=$(az account show --query 'id' -o tsv) |
| 40 | + active_name=$(az account show --query 'name' -o tsv) |
| 41 | + if [[ "$active_name" != "$AZURE_SUBSCRIPTION" && "$active_id" != "$AZURE_SUBSCRIPTION" ]]; then |
| 42 | + warn "active='$active_name' ($active_id), expected='$AZURE_SUBSCRIPTION'" |
| 43 | + die "wrong subscription. Run: az account set --subscription '$AZURE_SUBSCRIPTION'" |
| 44 | + fi |
| 45 | + export AZURE_SUBSCRIPTION_ID="$active_id" |
| 46 | +} |
| 47 | + |
| 48 | +require_environment() { |
| 49 | + case "${ENVIRONMENT:-}" in |
| 50 | + dev|test|prod) ;; |
| 51 | + *) die "ENVIRONMENT must be dev|test|prod (got '${ENVIRONMENT:-}')" ;; |
| 52 | + esac |
| 53 | +} |
| 54 | + |
| 55 | +require_active_subscription_role() { |
| 56 | + local sub=${AZURE_SUBSCRIPTION_ID:?require_subscription must run first} |
| 57 | + local perms has |
| 58 | + |
| 59 | + perms=$(az rest --method GET \ |
| 60 | + --url "https://management.azure.com/subscriptions/$sub/providers/Microsoft.Authorization/permissions?api-version=$ARM_API_VERSION" \ |
| 61 | + 2>/dev/null) || perms='{"value":[]}' |
| 62 | + |
| 63 | + # shellcheck disable=SC2016 |
| 64 | + local jq_filter=' |
| 65 | + def covers(p; a): |
| 66 | + (p == "*") or (p == a) or |
| 67 | + (p | endswith("/*") and (a | startswith(p[:-1]))); |
| 68 | + [ .value[]? |
| 69 | + | select(any(.actions[]?; covers(.; $action))) |
| 70 | + | select(all(.notActions[]?; covers(.; $action) | not)) |
| 71 | + ] | length' |
| 72 | + has=$(jq -r --arg action "$RBAC_DEPLOY_ACTION" "$jq_filter" <<<"$perms") |
| 73 | + [[ "${has:-0}" -gt 0 ]] && return 0 |
| 74 | + cat >&2 <<EOF |
| 75 | +ERROR (require_active_subscription_role): no Owner or Contributor RBAC role is |
| 76 | +active on subscription '$AZURE_SUBSCRIPTION'. Activate one via PIM: |
| 77 | + https://portal.azure.com → Privileged Identity Management → My roles → |
| 78 | + Azure resources → Owner or Contributor → Activate (scope: $sub) |
| 79 | +then re-run this task. |
| 80 | +EOF |
| 81 | + exit 1 |
| 82 | +} |
| 83 | + |
| 84 | +require_active_app_developer_role() { |
| 85 | + local roles |
| 86 | + roles=$(az rest --method GET \ |
| 87 | + --url 'https://graph.microsoft.com/v1.0/me/memberOf' \ |
| 88 | + --query "value[?\"@odata.type\"=='#microsoft.graph.directoryRole'].displayName" \ |
| 89 | + -o tsv) |
| 90 | + grep -qFx "$REQUIRED_DIR_ROLE" <<<"$roles" || { |
| 91 | + cat >&2 <<EOF |
| 92 | +ERROR (require_active_app_developer_role): the '$REQUIRED_DIR_ROLE' Entra ID directory role is |
| 93 | +not active. Activate it via PIM (https://portal.azure.com → Privileged |
| 94 | +Identity Management → My roles → Microsoft Entra roles → $REQUIRED_DIR_ROLE |
| 95 | +→ Activate) and re-run this task. |
| 96 | +Active directory roles: ${roles:-(none)} |
| 97 | +EOF |
| 98 | + exit 1 |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +resolve_owners() { |
| 103 | + local me members count |
| 104 | + me=$(az ad signed-in-user show --query id -o tsv) |
| 105 | + members=$(az ad group member list --group "$APP_OWNERS_GROUP" --query '[].id' -o json) |
| 106 | + count=$(jq 'length' <<<"$members") |
| 107 | + [[ "$count" -gt 0 ]] || die \ |
| 108 | + "group '$APP_OWNERS_GROUP' has no members; refusing to register apps with only the signed-in user as owner." |
| 109 | + jq -c --arg me "$me" 'if index($me) then . else [$me] + . end' <<<"$members" |
| 110 | +} |
| 111 | + |
| 112 | +deploy_bicep() { |
| 113 | + local kind=$1 name=$2 tmpl=$3 params=$4 mode=${5:-apply} |
| 114 | + case "$mode" in |
| 115 | + --validate) |
| 116 | + az bicep build --file "$tmpl" --stdout >/dev/null |
| 117 | + info "validated $tmpl" |
| 118 | + return |
| 119 | + ;; |
| 120 | + --dry-run) |
| 121 | + az deployment sub what-if --name "$name" --location "$LOCATION" \ |
| 122 | + --template-file "$tmpl" --parameters "$params" \ |
| 123 | + --exclude-change-types Ignore NoChange \ |
| 124 | + --result-format FullResourcePayloads |
| 125 | + return |
| 126 | + ;; |
| 127 | + esac |
| 128 | + case "$kind" in |
| 129 | + stack) |
| 130 | + az stack sub create --name "$name" --location "$LOCATION" \ |
| 131 | + --template-file "$tmpl" --parameters "$params" \ |
| 132 | + --tags "application=$APPLICATION_NAME" \ |
| 133 | + "environment=$ENVIRONMENT" "managedBy=bicep" \ |
| 134 | + --action-on-unmanage detachAll \ |
| 135 | + --deny-settings-mode denyWriteAndDelete --yes |
| 136 | + ;; |
| 137 | + sub) |
| 138 | + az deployment sub create --name "$name" --location "$LOCATION" \ |
| 139 | + --template-file "$tmpl" --parameters "$params" |
| 140 | + ;; |
| 141 | + *) die "unknown kind '$kind' (expected stack|sub)" ;; |
| 142 | + esac |
| 143 | +} |
| 144 | + |
| 145 | +create_oauth2_secret() { |
| 146 | + local env=$1 rotate=${2:-} display app_id cred existing out |
| 147 | + display="$APPLICATION_NAME-oauth2-$env" |
| 148 | + app_id=$(az ad app list --display-name "$display" --query '[0].appId' -o tsv) |
| 149 | + [[ -n "$app_id" ]] || die "app '$display' not found." |
| 150 | + cred="oauth2-proxy ($display)" |
| 151 | + existing=$(az ad app credential list --id "$app_id" \ |
| 152 | + --query "[?displayName=='$cred'].hint" -o tsv) |
| 153 | + if [[ -n "$existing" && "$rotate" != "--rotate" ]]; then |
| 154 | + info "secret '$cred' exists (hint: ${existing}***). Pass --rotate to generate a new one." |
| 155 | + return 0 |
| 156 | + fi |
| 157 | + out="secrets/OAUTH2_CLIENT_SECRET_$env.txt" |
| 158 | + ( |
| 159 | + umask 077 |
| 160 | + mkdir -p secrets |
| 161 | + az ad app credential reset --id "$app_id" --append \ |
| 162 | + --display-name "$cred" --years 1 --query password -o tsv > "$out" |
| 163 | + ) |
| 164 | + info "wrote $out" |
| 165 | +} |
0 commit comments