🚧 Aprobaciones y puertas: seis capas entre el commit y Azure¶
1. Seis capas, y a quién detiene cada una¶
El original clasifica las puertas por tipo (automática, manual, temporal, de seguridad). Es más útil clasificarlas por dónde viven, porque eso determina a quién detienen. Las cuatro primeras capas están en el pipeline: detienen al pipeline. Las dos últimas están en Azure o miran a Azure: detienen a cualquiera.
| Capa | Qué detiene | A quién no detiene | Herramienta |
|---|---|---|---|
| 1. Rama | Fusionar sin revisión, sin que las comprobaciones pasen, con la rama desactualizada, sin el dueño del directorio | A quien tenga permiso de bypass; a quien no pase por Git | Rulesets, CODEOWNERS, required status checks; en DevOps, branch policies |
| 2. Código | Formato, sintaxis, configuraciones inseguras, secretos en el repositorio | A lo que solo se ve con el estado delante (qué se destruye) | fmt, validate, tflint, trivy, gitleaks (página 13) |
| 3. Plan | Destrucción de recursos con datos, reemplazos, ausencia de etiquetas, valores prohibidos | A quien ejecute Terraform fuera del pipeline | jq, conftest, terraform test (página 14) |
| 4. Humano | Lo que ninguna regla formula: "¿es este el momento?", "¿es esto lo que acordamos?" | A sí mismo, si aprueba sin leer; por eso necesita el resumen delante | Environments: revisores, wait timer, ramas; DevOps: Approvals, Business hours |
| 5. Azure | Borrar un recurso bloqueado, crear fuera de las regiones permitidas, sin etiqueta obligatoria, con una identidad sin rol | A un Owner que quite el bloqueo o la política primero (y eso queda en el Activity Log) | Bloqueos CanNotDelete, Azure Policy con deny, RBAC acotado (página 12) |
| 6. Verificación | Que pro reciba un cambio que en dev no funciona; que el apply deje deriva | Nada que ya haya pasado: es la última, mira hacia atrás | Pruebas de humo con az y curl, plan -detailed-exitcode = 0, escaneo de política |
⚠️ Toda puerta tiene que poder abrirse. Una puerta que nadie puede abrir se rodea (se desactiva el check, se aplica desde el portátil). El diseño correcto es que cada puerta se abra con un acto explícito y auditado: una etiqueta en la PR puesta por un dueño, un
workflow_dispatchcon campomotivoobligatorio, un commit propio que quita un bloqueo. Lo que se prohíbe no es pasar: es pasar sin dejar rastro.
2. Capa 1: la fusión es la primera puerta¶
El original no protege la rama: cualquier push a main dispara el apply. Con una regla de rama, main solo recibe fusiones de PRs que hayan pasado las comprobaciones exactas del workflow de la página 13 y que haya aprobado alguien distinto del autor; si el directorio infra/ tiene dueños, uno de ellos.
# .github/CODEOWNERS: quien debe aprobar cambios en infraestructura (equipo, no persona)
infra/ @moodle-org/plataforma
.github/workflows/ @moodle-org/plataforma # el pipeline es infraestructura: cambiarlo también requiere dueño
# Regla de rama (ruleset) sobre main: sin borrado, sin force-push, PR con una aprobación de CODEOWNERS,
# aprobaciones invalidadas si llegan commits nuevos, y las comprobaciones del workflow como requisito
gh api -X POST repos/{owner}/{repo}/rulesets --input - <<'EOF'
{
"name": "main", "target": "branch", "enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "pull_request", "parameters": {
"required_approving_review_count": 1, "require_code_owner_review": true,
"dismiss_stale_reviews_on_push": true, "require_last_push_approval": true,
"required_review_thread_resolution": true } },
{ "type": "required_status_checks", "parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [ { "context": "comprobar" }, { "context": "plan (dev)" }, { "context": "plan (pro)" } ] } }
]
}
EOF
# strict = la rama debe estar al día con main: el plan que se revisó se hizo sobre el código que se va a fusionar
# Los "context" son los nombres exactos de los jobs (con la matriz entre paréntesis). Si renombras un job, la regla deja de exigirlo.
gh api repos/{owner}/{repo}/rulesets --jq '.[].name'
# Azure DevOps: la política de rama equivale al ruleset
az repos policy approver-count create --branch main --repository-id <id> --minimum-approver-count 1 --creator-vote-counts false --reset-on-source-push true --blocking true --enabled true --org … -p …
az repos policy build create --branch main --repository-id <id> --build-definition-id <id del pipeline> --display-name "terraform plan" --manual-queue-only false --queue-on-source-update-only false --valid-duration 0 --blocking true --enabled true --org … -p …
az repos policy required-reviewer create --branch main --repository-id <id> --required-reviewer-ids <grupo plataforma> --path-filter "/infra/*" --blocking true --enabled true --org … -p …
3. Capa 4: la puerta humana, bien colocada¶
El original crea un job Approve_Prod con environment: 'Producción' cuyo único paso es un echo, y después un Deploy_Prod sin environment. La aprobación protege el echo; el apply corre libre. La regla es simple: el environment va en el job que ejecuta el apply, y ese job debe mostrar al revisor lo que va a aprobar antes de pedir la aprobación. Como la aprobación detiene el job antes de que empiece, el resumen tiene que venir de otro sitio: el comentario de la PR y la huella (página 14), enlazados desde el summary del job de plan.
# Environment moodle-pro: revisores, ramas, espera mínima y sin auto-aprobación
gh api -X PUT repos/{owner}/{repo}/environments/moodle-pro --input - <<EOF
{
"wait_timer": 10,
"prevent_self_review": true,
"reviewers": [ { "type": "Team", "id": $(gh api orgs/moodle-org/teams/plataforma --jq .id) } ],
"deployment_branch_policy": { "protected_branches": true, "custom_branch_policies": false }
}
EOF
# wait_timer (minutos): tiempo entre que el job queda listo y puede ejecutarse aunque ya esté aprobado. Diez minutos bastan
# para que un "aprobado" reflejo se pueda cancelar. No es una ventana horaria: para eso, un paso (15.6) o el check de DevOps.
# prevent_self_review: quien lanzó el workflow (el autor del merge) no puede aprobar su propio despliegue.
# Lo que el revisor tiene delante: el summary del job de plan enlaza al comentario de la PR y publica la huella
- run: |
{ echo "## Plan pro"; cat resumen.md; echo; echo "Huella: \`$(sha256sum huella.json | cut -c1-16)\`";
echo; echo "Políticas: conftest ✅ · terraform test ✅"; echo; echo "[Plan completo en la PR](${{ github.event.pull_request.html_url }})"; } >> "$GITHUB_STEP_SUMMARY"
# Quién aprobó, cuándo y con qué comentario: la auditoría de la capa 4
gh api repos/{owner}/{repo}/actions/runs/<run_id>/approvals --jq '.[] | {quien: .user.login, estado: .state, comentario: .comment, environments: [.environments[].name]}'
gh api repos/{owner}/{repo}/deployments --jq '.[] | select(.environment == "moodle-pro") | {sha, creador: .creator.login, cuando: .created_at}' | head -5
# Azure DevOps: los checks del environment, y el "gate" clásico que el original menciona ya no existe como tal
# Approvals → revisores (mínimo, plazo, ¿puede aprobar quien lanzó?: no)
# Branch control → solo refs/heads/main, y verificar que la rama está protegida
# Business hours → ventana horaria nativa (L-V 9:00-17:00 Europe/Madrid)
# Exclusive lock → una ejecución a la vez sobre el environment (página 13)
# Required template → el job debe venir de templates/tf.yml: nadie inventa otro apply
# Invoke Azure Function / REST API → una puerta programada (¿hay un incidente abierto? ¿está en cambio congelado?)
az devops invoke --area pipelines --resource approvals --route-parameters project=<proyecto> --api-version 7.1-preview --org … \
--query "value[].{quien:steps[0].actualApprover.displayName, estado:status, cuando:steps[0].lastModifiedOn}" -o table
4. Capa 5: las puertas que viven en Azure¶
Todo lo anterior detiene al pipeline. Estas tres puertas las evalúa Azure Resource Manager en cada petición, venga del pipeline, del portal, de az o de un Terraform ejecutado en un portátil. Y las tres se despliegan con Terraform, así que forman parte del código que las otras capas protegen.
# 1. Bloqueo de borrado: nadie borra el recurso (ni su grupo) mientras el bloqueo exista, sea quien sea
resource "azurerm_management_lock" "datos" {
count = var.proteger ? 1 : 0 # el interruptor: quitar el bloqueo es un cambio de código, con su PR
name = "no-borrar"
scope = azurerm_storage_account.moodledata.id
lock_level = "CanNotDelete" # ReadOnly también impide modificar: demasiado para un recurso vivo
notes = "Datos de Moodle. Quitar solo con proteger=false en un PR propio, aprobado en moodle-pro."
}
# Un destroy contra él: Error: deleting … StatusCode=409 … ScopeLocked … "The scope … cannot perform delete operation because following scope(s) are locked"
# Junto con prevent_destroy (página 14) son dos capas distintas: una la evalúa Terraform antes de intentarlo; otra Azure cuando lo intenta.
# 2. Azure Policy con efecto deny: ARM rechaza la petición que no cumple, en el apply, con RequestDisallowedByPolicy
resource "azurerm_resource_group_policy_assignment" "ubicaciones" {
name = "ubicaciones-permitidas"
resource_group_id = azurerm_resource_group.moodle.id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c" # built-in "Allowed locations"
parameters = jsonencode({ listOfAllowedLocations = { value = ["eastus", "westeurope"] } })
}
resource "azurerm_resource_group_policy_assignment" "tag_entorno" {
name = "tag-entorno-obligatoria"
resource_group_id = azurerm_resource_group.moodle.id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/871b6d14-10aa-478d-b590-94f262ecfa99" # built-in "Require a tag on resources"
parameters = jsonencode({ tagName = { value = "entorno" } })
}
# La política no la "consulta" el pipeline (el az policy state list del original mide lo que ya existe y tarda horas):
# la aplica ARM al recibir la petición. La puerta del pipeline es la de la capa 3, que detecta lo mismo antes y con mejor mensaje.
# 3. RBAC acotado (página 12): la identidad de dev no tiene rol sobre el grupo de pro. No es una comprobación: es la ausencia de permiso.
# El patrón de dos PRs para un cambio destructivo legítimo (retirar el storage antiguo tras una migración):
# PR 1: proteger = false (+ quitar prevent_destroy). Plan: "1 to destroy" del bloqueo, nada más. Se aprueba, se aplica.
# PR 2: eliminar el recurso. Plan: "1 to destroy". Etiqueta destruccion-aprobada (15.6), se aprueba, se aplica.
# Dos aprobaciones, dos entradas en el Activity Log, ninguna sorpresa en un plan mezclado con otros cambios.
🔷 El bloqueo del estado también. El grupo de recursos
rg-tfstatede la página 4 merece su propioCanNotDelete: perder el estado es peor que perder cualquier recurso, porque se pierde la capacidad de gestionarlos todos. Se crea desde la configuración de plataforma, no desde la de Moodle, para que undestroyde Moodle no pueda ni intentarlo.
5. Capa 6: la puerta entre dev y pro es que dev funcione¶
El original propone "canary releases" y "rollback automático". Para infraestructura, la progresión gradual es otra: el cambio se aplica en dev, se verifica que dev funciona, y solo entonces pro queda disponible para su aprobación. La verificación es un job con dos partes: pruebas de humo (el recurso existe con la configuración esperada y responde) y plan a cero (el apply no dejó nada pendiente ni deriva).
# En el workflow de la página 13, entre aplicar (dev) y aplicar (pro):
verificar_dev:
needs: aplicar # el job de matriz; con max-parallel: 1 y fail-fast, aquí dev ya terminó
if: needs.aplicar.result == 'success'
runs-on: ubuntu-latest
permissions: { id-token: write, contents: read }
environment: moodle-dev
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: ${{ env.TF_VERSION }}, terraform_wrapper: false }
- run: infra/ci/gate.sh humo dev # el script del laboratorio (15.6); solo lectura
env: { ARM_USE_OIDC: "true", ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID_dev }}, ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}, ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} }
aplicar_pro:
needs: verificar_dev # pro no existe como opción hasta que dev está verificado
environment: moodle-pro
…
# Lo que comprueba "humo" para Moodle (con lo que hay en Topaz: control plane + datos de storage/Key Vault):
# - terraform output -json → nombres reales, no supuestos
# - az storage account show → TLS mínimo, claves compartidas desactivadas, etiquetas presentes
# - az keyvault secret list → los secretos que Moodle necesita existen (sin leer valores)
# - terraform plan -detailed-exitcode → 0: el apply dejó el estado igual al código y a Azure
# En Azure real, además: curl -sf https://<fqdn>/login/index.php → 200 y az policy state trigger-scan sobre el grupo.
6. Laboratorio en Topaz¶
El laboratorio escribe ci/gate.sh con cuatro puertas que son scripts (destrucción con apertura por etiqueta, ventana horaria con apertura por motivo, pruebas de humo y plan a cero) y las ejecuta contra Topaz igual que las ejecutaría el runner. Además despliega con Terraform las puertas de la capa 5 (bloqueo de borrado y asignación de política) y recorre el patrón de dos PRs. Lo que evalúa ARM (que el bloqueo rechace un delete, que la política rechace una región) se comprueba en el bloque de Azure real.
mkdir -p ~/tf-gates/ci && cd ~/tf-gates && cp ~/tf-st/providers.tf .
# ─── 1. Código con las puertas de la capa 5 ──────────────────────────────────────
cat > main.tf <<'EOF'
variable "entorno" { type = string, default = "dev" }
variable "location" { type = string, default = "eastus" }
variable "proteger" { type = bool, default = true } # bloqueo de borrado
variable "politicas" { type = bool, default = false } # asignación de Azure Policy: se activa en Azure real
locals { tags = { proyecto = "moodle", entorno = var.entorno, gestion = "terraform" } }
resource "azurerm_resource_group" "lab" { name = "rg-gates-lab-${var.entorno}", location = var.location, tags = local.tags }
resource "azurerm_storage_account" "datos" {
name = "stgates${substr(md5(azurerm_resource_group.lab.id), 0, 8)}"
resource_group_name = azurerm_resource_group.lab.name, location = azurerm_resource_group.lab.location
account_tier = "Standard", account_replication_type = "LRS", min_tls_version = "TLS1_2", shared_access_key_enabled = false
tags = local.tags
}
resource "azurerm_management_lock" "datos" {
count = var.proteger ? 1 : 0
name = "no-borrar"
scope = azurerm_storage_account.datos.id
lock_level = "CanNotDelete"
notes = "Datos de Moodle. Quitar solo con proteger=false en un PR propio, aprobado en moodle-pro."
}
resource "azurerm_resource_group_policy_assignment" "ubicaciones" {
count = var.politicas ? 1 : 0
name = "ubicaciones-permitidas"
resource_group_id = azurerm_resource_group.lab.id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c"
parameters = jsonencode({ listOfAllowedLocations = { value = ["eastus", "westeurope"] } })
}
output "storage" { value = azurerm_storage_account.datos.name }
output "grupo" { value = azurerm_resource_group.lab.name }
EOF
# ─── 2. El script de puertas: lo que el runner ejecuta ───────────────────────────
cat > ci/gate.sh <<'EOF'
#!/usr/bin/env bash
# Uso: ci/gate.sh destruccion <plan.tfplan> [etiquetas.json] | horario | humo <entorno>
set -euo pipefail
cd "$(dirname "$0")/.."
protegidos='["azurerm_storage_account","azurerm_key_vault","azurerm_mysql_flexible_server","azurerm_management_lock"]'
case "$1" in
destruccion)
plan=$2; etiquetas=${3:-/dev/null}
borrados=$(terraform show -json "$plan" | jq -r --argjson p "$protegidos" \
'[.resource_changes[] | select(.change.actions | index("delete")) | select(.type | IN($p[]))] | .[].address')
[ -z "$borrados" ] && { echo "sin destrucciones de recursos protegidos"; exit 0; }
if jq -e 'index("destruccion-aprobada")' "$etiquetas" >/dev/null 2>&1; then
echo "::warning::Destrucción autorizada por etiqueta destruccion-aprobada:"; echo "$borrados"; exit 0
fi
echo "::error::El plan destruye recursos protegidos y la PR no lleva la etiqueta destruccion-aprobada:"; echo "$borrados"; exit 1 ;;
horario)
export TZ=Europe/Madrid; dia=$(date +%u); hora=$(date +%H)
if [ "${FORZAR_HORARIO:-}" = "1" ]; then echo "::warning::Ventana horaria forzada. Motivo: ${MOTIVO:?FORZAR_HORARIO exige MOTIVO}"; exit 0; fi
if [ "$dia" -ge 6 ] || [ "$hora" -lt 9 ] || [ "$hora" -ge 17 ]; then echo "::error::Fuera de ventana L-V 09:00-17:00 $TZ: $(date)"; exit 1; fi
echo "dentro de ventana: $(date)" ;;
humo)
env=$2; st=$(terraform output -raw storage); rg=$(terraform output -raw grupo)
az storage account show -n "$st" -g "$rg" --query "{tls:minimumTlsVersion, claves:allowSharedKeyAccess, entorno:tags.entorno}" -o json | tee humo.json
jq -e --arg e "$env" '.tls == "TLS1_2" and .claves == false and .entorno == $e' humo.json >/dev/null || { echo "::error::humo: configuración inesperada"; exit 1; }
set +e; terraform plan -detailed-exitcode -var entorno="$env" >/dev/null; code=$?; set -e
[ "$code" -eq 0 ] || { echo "::error::humo: el plan no está a cero (código $code)"; exit 1; }
echo "humo OK: configuración correcta y plan a cero" ;;
esac
EOF
chmod +x ci/gate.sh
terraform init >/dev/null
# ─── 3. Plan limpio pasa la puerta de destrucción; apply; humo ───────────────────
terraform plan -out=plan.tfplan >/dev/null
ci/gate.sh destruccion plan.tfplan; echo "salida: $?" # 0: sin destrucciones
terraform apply plan.tfplan
az lock list -g rg-gates-lab-dev --resource-type Microsoft.Storage/storageAccounts --resource-name "$(terraform output -raw storage)" -o table # no-borrar · CanNotDelete
ci/gate.sh humo dev; echo "salida: $?" # humo OK, plan a cero
# ─── 4. La puerta de destrucción, cerrada y abierta ──────────────────────────────
terraform plan -destroy -out=borra.tfplan >/dev/null
ci/gate.sh destruccion borra.tfplan; echo "salida: $?" # ::error:: … azurerm_storage_account.datos, azurerm_management_lock.datos[0] → 1
echo '["revisado","destruccion-aprobada"]' > etiquetas.json # lo que el workflow pasa con toJSON(github.event.pull_request.labels.*.name)
ci/gate.sh destruccion borra.tfplan etiquetas.json; echo "salida: $?" # ::warning:: autorizada → 0. La etiqueta la pone un CODEOWNER, y queda en la PR.
echo '["revisado"]' > etiquetas.json && ci/gate.sh destruccion borra.tfplan etiquetas.json; echo "salida: $?" # 1 otra vez
# ─── 5. Patrón de dos PRs: primero el bloqueo, después el recurso ────────────────
terraform plan -var proteger=false -out=pr1.tfplan # Plan: 0 to add, 0 to change, 1 to destroy ← solo el bloqueo
ci/gate.sh destruccion pr1.tfplan; echo "salida: $?" # 1: azurerm_management_lock es protegido; también pide etiqueta
terraform apply pr1.tfplan # "PR 1" aplicada
az lock list -g rg-gates-lab-dev -o table # vacío
terraform plan -destroy -var proteger=false -out=pr2.tfplan # "PR 2": 2 to destroy (storage y grupo); en el caso real, solo el recurso retirado
ci/gate.sh destruccion pr2.tfplan <(echo '["destruccion-aprobada"]'); echo "salida: $?" # 0 con etiqueta
terraform apply -var proteger=true -auto-approve # volvemos a proteger para los pasos siguientes
# ─── 6. Ventana horaria y su apertura auditada ───────────────────────────────────
ci/gate.sh horario; echo "salida: $?" # 0 o 1 según la hora en que leas esto
FORZAR_HORARIO=1 ci/gate.sh horario; echo "salida: $?" # error: FORZAR_HORARIO exige MOTIVO → 1
FORZAR_HORARIO=1 MOTIVO="INC-4821: certificado caducado" ci/gate.sh horario; echo "salida: $?" # ::warning:: con el motivo en el log → 0
# En el workflow: FORZAR_HORARIO y MOTIVO vienen de inputs de workflow_dispatch (motivo required: true); en un push no existen.
# ─── 7. Humo que falla: deriva tras el apply ─────────────────────────────────────
az storage account update -n "$(terraform output -raw storage)" -g rg-gates-lab-dev --min-tls-version TLS1_0 -o none
ci/gate.sh humo dev; echo "salida: $?" # ::error:: configuración inesperada → 1: pro no se abre
terraform apply -auto-approve >/dev/null && ci/gate.sh humo dev # el apply corrige; humo OK
# ─── 8. Limpiar (en el orden que el bloqueo obliga) ──────────────────────────────
terraform apply -var proteger=false -auto-approve # quitar el bloqueo es un paso propio, también aquí
terraform destroy -var proteger=false -auto-approve
rm -f *.tfplan etiquetas.json humo.json
# ─── Solo Azure real ────────────────────────────────────────────────────────────
# A. El bloqueo detiene a cualquiera: a az, al portal y a Terraform
terraform apply -auto-approve # con proteger=true
az storage account delete -n "$(terraform output -raw storage)" -g rg-gates-lab-dev --yes
# (ScopeLocked) The scope '/subscriptions/…/storageAccounts/stgates…' cannot perform delete operation because following scope(s) are locked: … Please remove the lock and try again.
terraform destroy -auto-approve
# Error: deleting Storage Account … StatusCode=409 … ScopeLocked ← el destroy desde el portátil muere aquí, sin pipeline
az monitor activity-log list -g rg-gates-lab-dev --offset 10m --query "[?contains(operationName.value,'delete')].{quien:caller, que:operationName.localizedValue, estado:status.value}" -o table
# los intentos fallidos también quedan registrados: la puerta de la capa 5 deja rastro de quién la empujó
# B. La política la evalúa ARM en la petición, con el mensaje que el pipeline no tendría que dar
terraform apply -var politicas=true -auto-approve
az storage account create -n stfuera$RANDOM -g rg-gates-lab-dev -l northeurope --sku Standard_LRS
# (RequestDisallowedByPolicy) Resource 'stfuera…' was disallowed by policy. Policy identifiers: … "ubicaciones-permitidas" …
az policy state trigger-scan -g rg-gates-lab-dev && az policy state summarize -g rg-gates-lab-dev --query "results.nonCompliantResources" # la evaluación de lo existente, para la capa 6
# C. Las puertas del pipeline (GitHub): ruleset, environment, etiquetas y auditoría
gh api -X POST repos/{owner}/{repo}/rulesets --input ruleset-main.json # 15.2
gh api -X PUT repos/{owner}/{repo}/environments/moodle-pro --input env-pro.json # 15.3: wait_timer, prevent_self_review, equipo revisor
gh label create destruccion-aprobada --color B60205 --description "Un CODEOWNER autoriza las destrucciones de este plan"
# En el job de plan: - run: ci/gate.sh destruccion plan.tfplan <(echo '${{ toJSON(github.event.pull_request.labels.*.name) }}')
# Solo un equipo debería poder etiquetar: en Settings → Moderation, o comprobando en el script quién puso la etiqueta con gh api …/issues/N/events
gh api repos/{owner}/{repo}/actions/runs/<run_id>/approvals --jq '.[] | {quien: .user.login, estado: .state, comentario: .comment}'
gh api repos/{owner}/{repo}/rulesets/<id> --jq '.rules[].type' # qué exige main hoy
gh api "repos/{owner}/{repo}/rules/branches/main" --jq '.[].type' # lo mismo, visto desde la rama (incluye rulesets de la organización)
# D. Azure DevOps: checks del environment y auditoría
az pipelines environment list --org … -p … -o table
az devops invoke --area pipelines --resource approvals --route-parameters project=<proyecto> --api-version 7.1-preview --org … \
--query "value[].{quien:steps[0].actualApprover.displayName, estado:status, cuando:steps[0].lastModifiedOn}" -o table
az devops security permission list … | grep -i "bypass" # quién puede saltarse las políticas de rama: esa lista debería ser muy corta
7. Errores comunes¶
⚠️ Solución de problemas
Mensaje o síntoma Causa y solución grep -q "destroy" tfplannunca bloquea nada (el original)El fichero de plan es un ZIP binario. La destrucción se detecta en el JSON de terraform show -json, enresource_changes[].change.actions(página 14); el scriptdestrucciondel laboratorioEl job de aprobación tiene el environment y el de apply no (el original) La aprobación protege un echo; el apply corre libre. El environment va en el job que ejecutaterraform applyif [ $? -ne 0 ]en un paso nuevo tras CodeQL o las pruebas (el original)Cada paso empieza con $?a 0: la condición nunca se cumple. Un paso que falla ya detiene el job; no hace falta comprobarlo después. Y CodeQL no analiza HCL: la puerta de código es tflint/trivy/gitleakstrstringer/manual-approvalconAPPROVAL_SECRETeissue-numbervacíoAcción de terceros que abre un issue y espera comentarios; en un evento pushno hay número de PR. La aprobación nativa son los environments con revisores, wait timer y prevent self-reviewaz policy state listcomo puerta previa al apply (el original)Mide el cumplimiento de lo que ya existe, con horas de retraso; no evalúa el cambio. La política con deny la aplica ARM en el propio apply; lo que quieres saber antes lo dice la capa 3 sobre el plan RequestDisallowedByPolicy en el apply, con el plan aprobado Una política de Azure que la capa 3 no replica. Añade la regla equivalente a conftest para que el mensaje llegue en la PR, no en el apply; y comprueba con az policy assignment list --disable-scope-strict-matchqué políticas heredan del grupo o la suscripciónScopeLocked (409) en un destroy que sí querías hacer La puerta funciona. Patrón de dos PRs: primero proteger = false, aprobado y aplicado; después el recurso. Nuncaaz lock deletea mano antes de un apply: es exactamente lo que el Activity Log mostraría como sospechosoEl bloqueo CanNotDeleteno aparece en el plan de destroy como errorTerraform no sabe de bloqueos hasta que Azure rechaza la petición: el plan de destroy siempre parece viable. La capa que avisa antes es prevent_destroy(página 14); las dos juntas cubren plan y applyUn plan de destroy sobre el bloqueo y el recurso a la vez Terraform borra primero el bloqueo (depende del recurso) y después el recurso: el bloqueo no protege de un destroy que también lo elimina a él. Por eso azurerm_management_lockestá en la lista de protegidos del script, y por eso el bloqueo del estado se crea desde otra configuración"El job de pro no se dispara nunca" con required reviewers Está esperando aprobación en la pestaña del run, sin avisar a nadie si no hay notificaciones configuradas. Revisores como equipo (no persona), notificaciones del equipo activas, y el summary del job de plan con el enlace El autor del cambio aprueba su propio despliegue prevent_self_review: trueen el environment; en la rama,require_last_push_approvalpara que quien empujó el último commit no cuente como aprobador. En DevOps, "el solicitante no puede aprobar" en el checkLa regla de rama exige un check que ya no existe (Expected — Waiting for status to be reported) Renombraste el job o cambió el nombre de la matriz. Los contextdel ruleset son nombres exactos; actualízalos en el mismo PR que cambia el workflowUn Owner del repositorio fusiona sin cumplir la regla Tiene bypass. Revisa bypass_actorsdel ruleset: debería estar vacío o limitarse a una app de emergencia. El que puede saltarse la puerta es parte del modelo de amenazawait_timerusado como ventana horariaEs una espera relativa (minutos desde que el job queda listo), no un horario. La ventana es el paso horariocon apertura porMOTIVO, o el check Business hours en DevOpsLa etiqueta destruccion-aprobadala puede poner cualquieraPoner etiquetas requiere permiso triage; si el equipo es amplio, el script debe comprobar quién la puso ( gh api …/issues/N/events, eventolabeled) contra la lista de CODEOWNERS"Rollback automático" y "canary" para infraestructura (el original) Un recurso no se despliega al 10 %. La progresión es dev → verificación (humo, plan a cero) → pro; el retroceso es git revertpor el mismo flujo (página 14)Demasiadas puertas: el equipo aplica desde el portátil para "ir rápido" Señal de que una puerta no tiene apertura razonable. Cada puerta debe abrirse con un acto auditado y proporcionado; y la capa 5 (RBAC: nadie tiene rol de escritura en pro salvo la identidad del pipeline) hace que el portátil no sea una opción En Topaz: el bloqueo se crea pero un az storage account deletelo ignora; la asignación de política no se puede crear o no rechaza nadaEsperado: el emulador acepta los tipos Microsoft.Authorization/*en distinto grado según la versión, y no evalúa políticas ni bloqueos en las peticiones. Por eso el laboratorio no intenta borrar con el bloqueo puesto, ypoliticasva afalse. Ambas cosas se ven en el bloque de Azure real
8. Autoevaluación¶
- ¿Cuáles son las seis capas y qué distingue a las dos últimas? Rama, código, plan, humano, Azure, verificación. Las cuatro primeras detienen al pipeline; las de Azure detienen a cualquiera, y la de verificación mira hacia atrás.
- ¿Por qué
grep "destroy" tfplanno bloquea nada? El fichero de plan es un ZIP binario. La destrucción se detecta en el JSON deterraform show -json, enresource_changes[].change.actions. - ¿En qué job va el environment con revisores y por qué?
En el que ejecuta
terraform apply. Si va en otro, la aprobación protege ese otro job y el apply corre sin ella. - ¿Qué garantiza
strict_required_status_checks_policy? Que la rama esté al día conmainantes de fusionar: el plan que se revisó se hizo sobre el código que realmente se va a fusionar. - ¿Qué hace
wait_timery qué no hace? Espera un número de minutos desde que el job queda listo, aunque ya esté aprobado; da margen para cancelar una aprobación reflejo. No es una ventana horaria. - ¿Por qué
az policy state listno sirve como puerta previa? Evalúa lo que ya existe, con retraso de horas, no el cambio propuesto. La política con deny la aplica ARM en la petición; lo que se quiere saber antes lo da la capa 3 sobre el plan. - ¿Qué diferencia hay entre
prevent_destroyy un bloqueoCanNotDelete?prevent_destroylo evalúa Terraform al planificar, solo para quien use ese código. El bloqueo lo evalúa Azure al recibir la petición, para cualquiera. Son capas distintas y se complementan. - ¿Por qué el bloqueo no protege de un destroy que también lo elimina a él?
Terraform borra primero el bloqueo (depende del recurso) y luego el recurso. Por eso el script trata
azurerm_management_lockcomo protegido y el bloqueo del estado se crea desde otra configuración. - Describe el patrón de dos PRs.
PR 1 quita la protección (bloqueo,
prevent_destroy): su plan solo destruye el bloqueo. PR 2 elimina el recurso, con etiqueta de autorización. Dos aprobaciones, dos rastros, sin destrucciones mezcladas con otros cambios. - ¿Cómo se abre cada puerta de forma auditada?
Destrucción: etiqueta puesta por un CODEOWNER. Horario:
workflow_dispatchconmotivoobligatorio. Bloqueo: commit propio que cambiaproteger. Lo prohibido no es pasar, es pasar sin rastro. - ¿Qué comprueba la puerta de verificación entre dev y pro?
Que los recursos existen con la configuración esperada (humo, solo lectura) y que
plan -detailed-exitcodedevuelve 0: el apply no dejó nada pendiente ni deriva. - ¿Dónde queda registrado quién aprobó un despliegue?
GitHub:
/actions/runs/{id}/approvalsy los deployments del environment. DevOps: la API de approvals. Y en Azure, el Activity Log muestra qué identidad ejecutó cada operación, incluidos los intentos rechazados.
9. Referencias¶
- Reglas disponibles en rulesets y CODEOWNERS
- Environments (required reviewers, wait timer, prevent self-review, deployment branches) y historial de aprobaciones (REST)
- Approvals and checks en Azure DevOps (Approvals, Branch control, Business hours, Exclusive lock, Required template, Invoke REST API) y directivas de rama
- Bloqueos de recursos (
CanNotDelete,ReadOnly) yazurerm_management_lock - Azure Policy: efecto deny, definiciones integradas y
azurerm_resource_group_policy_assignment workflow_dispatchcon inputs y resumen del job (GITHUB_STEP_SUMMARY)- Activity Log de Azure (quién intentó qué, incluidos los rechazos)
- Azure Local Emulator (Topaz)