Skip to content

Commit 980b864

Browse files
committed
refactor: iac deployment
1 parent 63b0383 commit 980b864

13 files changed

Lines changed: 439 additions & 431 deletions

.mise/config.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@ python = "3.14.4"
33
node = { version = "24.14.1", postinstall = "mise tasks run tools:yarn" }
44
pre-commit = "4.5.1"
55
uv = "0.11.6"
6+
shellcheck = "0.10.0"
7+
8+
[env]
9+
# Name for resource group, Azure resources, app registrations, and Docker image tags.
10+
# Lowercase alphanumeric and hyphens only. Change once when adopting this template.
11+
APPLICATION_NAME = "template-fastapi-react"
12+
13+
# Entra ID security group whose members co-own the app registrations alongside the deployer.
14+
# Empty groups are rejected.
15+
APP_OWNERS_GROUP = "Team Hermes Radix Admin"
16+
17+
# ServiceNow Business Application ID written to each app registration's
18+
# `serviceManagementReference` field. Required by Equinor compliance for
19+
# any app registration.
20+
SERVICE_MANAGEMENT_REFERENCE = "108392"
21+
22+
# Azure subscription (name or id) all `iac:*` tasks deploy into.
23+
# The task aborts if `az account show` returns anything else.
24+
AZURE_SUBSCRIPTION = "R070-WellCoreDB"
25+
26+
# Recipients for App Insights exception alerts (severity 1, 1-hour window).
27+
# JSON array of email addresses, e.g. '["a@equinor.com","b@equinor.com"]'.
28+
# "[]" disables alert delivery.
29+
ALERT_EMAIL_RECIPIENTS = "[]"
630

731
[task_config]
832
includes = [".mise/tasks/*.toml"]

.mise/tasks/iac.toml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
["iac:infra"]
2+
description = "Deploy Azure resources for an environment"
3+
dir = "{{config_root}}"
4+
raw = true
5+
usage = '''
6+
arg "<env>" help="Target environment (dev|test|prod)"
7+
flag "--dry-run" help="Run what-if; do not modify resources"
8+
'''
9+
run = '''
10+
source IaC/_lib.sh
11+
init_task "${usage_env:?}"
12+
require_config POSTGRES_DB_PASSWORD
13+
[[ "${usage_dry_run:-false}" == "true" ]] && DRY_RUN=--dry-run || DRY_RUN=
14+
deploy_bicep stack "$APPLICATION_NAME-$ENVIRONMENT-resources" \
15+
IaC/main.bicep IaC/main.bicepparam \
16+
$DRY_RUN
17+
'''
18+
19+
["iac:appreg"]
20+
description = "Deploy app registrations and create the oauth2-proxy client secret"
21+
dir = "{{config_root}}"
22+
raw = true
23+
usage = '''
24+
arg "<env>" help="Target environment (dev|test|prod)"
25+
flag "--dry-run" help="Run what-if; do not modify resources"
26+
flag "--rotate" help="Generate a new oauth2-proxy client secret"
27+
'''
28+
run = '''
29+
source IaC/_lib.sh
30+
init_task "${usage_env:?}"
31+
require_config APP_OWNERS_GROUP
32+
require_config SERVICE_MANAGEMENT_REFERENCE
33+
require_active_app_developer_role
34+
[[ "${usage_dry_run:-false}" == "true" ]] && DRY_RUN=--dry-run || DRY_RUN=
35+
[[ "${usage_rotate:-false}" == "true" ]] && ROTATE=--rotate || ROTATE=
36+
export OWNER_OBJECT_IDS=$(resolve_owners)
37+
deploy_bicep sub "$APPLICATION_NAME-app-registration-$ENVIRONMENT" \
38+
IaC/app-registration.bicep IaC/app-registration.bicepparam \
39+
$DRY_RUN
40+
[[ -n "$DRY_RUN" ]] && exit 0
41+
create_oauth2_secret "$ENVIRONMENT" $ROTATE
42+
'''
43+
44+
["iac:rotate-secret"]
45+
description = "Rotate the oauth2-proxy client secret"
46+
dir = "{{config_root}}"
47+
raw = true
48+
usage = 'arg "<env>" help="Target environment (dev|test|prod)"'
49+
run = '''
50+
source IaC/_lib.sh
51+
init_task "${usage_env:?}"
52+
create_oauth2_secret "$ENVIRONMENT" --rotate
53+
'''

.mise/tasks/lint.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,16 @@ run = [
2121
"mise run lint:web:typecheck",
2222
]
2323

24+
["lint:shell"]
25+
description = "Lint shell scripts with shellcheck"
26+
dir = "{{config_root}}"
27+
run = "shellcheck --shell=bash --external-sources IaC/*.sh"
28+
2429
[lint]
2530
description = "Lint the entire project"
2631
run = [
2732
"mise fmt",
33+
"mise run lint:shell",
2834
"mise run lint:api",
2935
"mise run lint:web",
3036
]

IaC/_lib.sh

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
}

IaC/app-registration.bicep

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ param productionHostnames string[] = []
2929

3030
var apiAccessScopeId = guid('api-access-${applicationName}')
3131
var adminRoleId = guid('admin-role-${applicationName}')
32+
var defaultRoleId = guid('default-role-${applicationName}')
3233

3334
var productionRedirectUris = [for host in productionHostnames: 'https://${host}/oauth2/callback']
3435
var productionSwaggerRedirectUris = [for host in productionHostnames: 'https://${host}/api/docs/oauth2-redirect']
@@ -93,11 +94,19 @@ resource apiApp 'Microsoft.Graph/applications@v1.0' = {
9394
]
9495
}
9596
appRoles: [
97+
{
98+
id: defaultRoleId
99+
allowedMemberTypes: ['User']
100+
description: 'Default User Role'
101+
displayName: 'default'
102+
isEnabled: true
103+
value: 'default'
104+
}
96105
{
97106
id: adminRoleId
98-
allowedMemberTypes: ['User', 'Application']
99-
description: '${applicationName} administrators.'
100-
displayName: 'Admin'
107+
allowedMemberTypes: ['User']
108+
description: 'Administrator Role'
109+
displayName: 'admin'
101110
isEnabled: true
102111
value: 'admin'
103112
}
@@ -167,6 +176,24 @@ resource oauth2App 'Microsoft.Graph/applications@v1.0' = {
167176
]
168177
}
169178
]
179+
appRoles: [
180+
{
181+
id: defaultRoleId
182+
allowedMemberTypes: ['User']
183+
description: 'Default User Role'
184+
displayName: 'default'
185+
isEnabled: true
186+
value: 'default'
187+
}
188+
{
189+
id: adminRoleId
190+
allowedMemberTypes: ['User']
191+
description: 'Administrator Role'
192+
displayName: 'admin'
193+
isEnabled: true
194+
value: 'admin'
195+
}
196+
]
170197
}
171198

172199
resource oauth2AppSP 'Microsoft.Graph/servicePrincipals@v1.0' = {

IaC/app-registration.bicepparam

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using './app-registration.bicep'
2+
3+
var env = readEnvironmentVariable('ENVIRONMENT')
4+
5+
param applicationName = readEnvironmentVariable('APPLICATION_NAME')
6+
param environment = env
7+
param ownerObjectIds = json(readEnvironmentVariable('OWNER_OBJECT_IDS', '[]'))
8+
param serviceManagementReference = readEnvironmentVariable('SERVICE_MANAGEMENT_REFERENCE')
9+
param productionHostnames = env == 'prod' ? ['template-fastapi-react.equinor.com'] : []

0 commit comments

Comments
 (0)