<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="es"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://luisadanmunoz.es/feed.xml" rel="self" type="application/atom+xml" /><link href="https://luisadanmunoz.es/" rel="alternate" type="text/html" hreflang="es" /><updated>2026-10-14T01:27:53+02:00</updated><id>https://luisadanmunoz.es/feed.xml</id><title type="html">Luis Adán Muñoz</title><subtitle>Blog técnico de Luis Adán Muñoz, arquitecto cloud: Azure, Terraform, agentes de IA con Microsoft Foundry, seguridad y costes, contado desde proyectos reales y con el código para probarlo.</subtitle><author><name>Luis Adán Muñoz</name></author><entry><title type="html">Portal de autoservicio con Entra ID, GDAP y Lighthouse</title><link href="https://luisadanmunoz.es/posts/portal-autoservicio-entra-id-gdap-lighthouse/" rel="alternate" type="text/html" title="Portal de autoservicio con Entra ID, GDAP y Lighthouse" /><published>2026-10-13T00:00:00+02:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/portal-autoservicio-entra-id-gdap-lighthouse</id><content type="html" xml:base="https://luisadanmunoz.es/posts/portal-autoservicio-entra-id-gdap-lighthouse/"><![CDATA[<p>Trabajo en un partner CSP de Microsoft y hay dos quejas que se repiten en casi todos los clientes: <strong>“no veo lo que me cuesta Azure”</strong> y <strong>“para resetear una contraseña tengo que abrir un ticket y esperar”</strong>. Las dos tienen solución con herramientas que Microsoft ya da a los partners, pero nadie las había juntado.</p>

<p>Empecé queriendo tener el rol de administrador en Microsoft 365 Lighthouse y acabé con un portal de autoservicio en el que el usuario del cliente pide la tarea, su responsable la aprueba con un clic desde el correo y la tarea se ejecuta sola en el tenant del cliente vía GDAP.</p>

<p>Esta es la historia de cómo llegué hasta ahí, con las decisiones, los callejones sin salida y los errores que me encontré por el camino.</p>

<hr />

<h2 id="la-regla-que-me-impuse-solo-lectura-hasta-entender-lo-que-hay">La regla que me impuse: solo lectura hasta entender lo que hay</h2>

<p>Antes de tocar nada en un entorno con decenas de clientes, todo lo que hice durante los primeros días fue <strong>de solo lectura</strong>: auditar, sondear APIs y medir. Solo cuando entendí el modelo de permisos pasé a escribir, y siempre primero en un tenant de laboratorio.</p>

<p>Y toda afirmación técnica, contrastada con Microsoft Learn. Para eso conecté el MCP oficial de Microsoft Learn a Claude y le pedí que cada respuesta viniera con su fuente. Varias veces me salvó de dar por buena una suposición.</p>

<hr />

<h2 id="fase-0-dos-capas-de-permisos-que-se-confunden">Fase 0: dos capas de permisos que se confunden</h2>

<p>Lo primero que aprendí es que en Lighthouse hay <strong>dos capas de permisos independientes</strong>, y casi todo el mundo las mezcla:</p>

<table>
  <thead>
    <tr>
      <th>Capa</th>
      <th>Dónde vive</th>
      <th>Qué controla</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>RBAC de Lighthouse</strong></td>
      <td>Tenant del partner</td>
      <td>Qué puedes ver y hacer <em>dentro de Lighthouse</em>: alertas, baselines, etiquetas, Sales Advisor</td>
    </tr>
    <tr>
      <td><strong>GDAP</strong></td>
      <td>Tenant de cada cliente</td>
      <td>Roles de Entra ID delegados que dan acceso real a los datos y la configuración del cliente</td>
    </tr>
  </tbody>
</table>

<p>El rol RBAC de Lighthouse <strong>no da acceso a los datos del cliente</strong>. Si un técnico “no puede hacer X” sobre un cliente, casi siempre falta un rol en la relación GDAP o la pertenencia a un grupo, no un rol de Lighthouse.</p>

<p>Dos detalles que conviene saber antes de pedir permisos:</p>

<ul>
  <li>El rol <strong>Administrador</strong> de Lighthouse concede automáticamente Administrador de roles con privilegios, Administrador de usuarios y Administrador de grupos en Entra ID, y Admin Agent en Partner Center. No es un rol para repartir a la ligera.</li>
  <li>Solo se puede asignar a un <strong>grupo con asignación de roles</strong> (requiere Entra ID P1). Cuando me ofrecieron reutilizar grupos existentes, resultó que eran de <em>Azure</em> Lighthouse, otro producto distinto. Usar uno de ellos habría dado control de GDAP sobre todos los clientes a gente que solo gestionaba Azure Arc. Creamos un grupo dedicado.</li>
</ul>

<hr />

<h2 id="fase-1-auditar-lo-que-ya-existe">Fase 1: auditar lo que ya existe</h2>

<p>Con el rol asignado, lo primero fue saber qué había. Escribí un script de PowerShell de <strong>solo lectura</strong> que vuelca a CSV las relaciones GDAP, sus asignaciones a grupos con los miembros de cada grupo, los tenants que ve Lighthouse y la directiva de costes de cada cliente:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Connect-MgGraph</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nv">$partnerTenantId</span><span class="w"> </span><span class="nt">-NoWelcome</span><span class="w"> </span><span class="nt">-Scopes</span><span class="w"> </span><span class="p">@(</span><span class="w">
  </span><span class="s1">'DelegatedAdminRelationship.Read.All'</span><span class="p">,</span><span class="w"> </span><span class="s1">'ManagedTenants.Read.All'</span><span class="p">,</span><span class="w">
  </span><span class="s1">'GroupMember.Read.All'</span><span class="p">,</span><span class="w"> </span><span class="s1">'RoleManagement.Read.Directory'</span><span class="p">)</span><span class="w">

</span><span class="nv">$rels</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="nx">GET</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="s2">"https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminRelationships"</span><span class="w"> </span><span class="nt">-OutputType</span><span class="w"> </span><span class="nx">PSObject</span><span class="p">)</span><span class="o">.</span><span class="nf">value</span><span class="w">
</span></code></pre></div></div>

<p>Lo que conviene buscar en una auditoría así:</p>

<ul>
  <li><strong>Global Administrator permanente.</strong> Microsoft desaconseja sustituir GA por la suma de todos los roles, y además <strong>una relación con GA no admite auto-extensión</strong>: caduca y hay que volver a pedir la aprobación del cliente.</li>
  <li><strong>Segregación de funciones.</strong> Si los grupos de soporte y de administración tienen exactamente los mismos roles, en la práctica no hay segregación.</li>
  <li><strong>Caducidades próximas</strong> y relaciones activas sin ningún grupo asignado (que nadie puede usar).</li>
  <li><strong>Solicitudes pendientes:</strong> caducan a los 90 días si el cliente no las aprueba.</li>
</ul>

<p>La conclusión fue incómoda pero útil: antes de vender un servicio basado en accesos delegados, hay que <strong>ordenar los propios accesos</strong>. No se puede ofrecer un informe de transparencia de accesos al cliente con un modelo de permisos desordenado.</p>

<hr />

<h2 id="fase-2-la-visibilidad-de-costes-que-era-lo-más-fácil">Fase 2: la visibilidad de costes, que era lo más fácil</h2>

<p>Esta era la queja comercial más repetida. Resulta que un partner puede activar, cliente a cliente, una directiva que permite al cliente ver <strong>Cost Management en su propio tenant</strong>. Está en el portal de Azure, en la cuenta Microsoft Partner Agreement, en <em>Clientes &gt; cliente &gt; Directivas</em>, y también por API:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$body</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">properties</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w"> </span><span class="nx">viewCharges</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Allowed"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Json</span><span class="w">
</span><span class="nx">Invoke-AzRestMethod</span><span class="w"> </span><span class="nt">-Method</span><span class="w"> </span><span class="nx">PUT</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Billing/billingAccounts/</span><span class="nv">$ba</span><span class="s2">/customers/</span><span class="nv">$customer</span><span class="s2">/policies/default?api-version=2020-05-01"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Payload</span><span class="w"> </span><span class="nv">$body</span><span class="w">
</span></code></pre></div></div>

<p>Con trampa: el cliente ve los costes <strong>a precio de lista (PAYG)</strong>, sin descuentos ni créditos del partner, y el uso de reservas aparece a coste cero. La función de <em>markup</em> de Cost Management solo existe para EA indirectos y para Azure operado por 21Vianet, no para CSP.</p>

<p>Para enseñar al cliente su precio real hay que exportar los costes desde el tenant del partner y aplicar el precio en un informe propio. Aun así, activar la directiva ya resuelve buena parte de la queja.</p>

<p>Dos tropiezos de portal que me hicieron perder tiempo:</p>

<ul>
  <li>La entrada <em>Clientes</em> solo aparece en la cuenta de tipo <strong>Microsoft Partner Agreement</strong>. En mi lista había dos cuentas con el mismo nombre, una MPA y otra MCA, y estaba entrando en la que no era.</li>
  <li>Para cambiar la directiva hay que ser <strong>Admin Agent</strong>. Ver la cuenta como administrador global no basta.</li>
</ul>

<hr />

<h2 id="fase-3-qué-se-puede-hacer-de-verdad-con-lighthouse-por-api">Fase 3: qué se puede hacer de verdad con Lighthouse por API</h2>

<p>Lighthouse tiene una API en Microsoft Graph (<code class="language-plaintext highlighter-rouge">/beta/tenantRelationships/managedTenants</code>), pero está en beta y <strong>solo admite permisos delegados</strong>. En lugar de fiarme de la documentación, escribí un sondeo que llama a cada colección y anota qué responde:</p>

<table>
  <thead>
    <tr>
      <th>Colección</th>
      <th>Resultado</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tenants</code>, <code class="language-plaintext highlighter-rouge">myRoles</code>, <code class="language-plaintext highlighter-rouge">tenantsDetailedInformation</code></td>
      <td>✅ Funcionan</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">credentialUserRegistrationsSummaries</code>, <code class="language-plaintext highlighter-rouge">conditionalAccessPolicyCoverages</code></td>
      <td>✅ Funcionan</td>
    </tr>
    <tr>
      <td>Cumplimiento de dispositivos, malware, protección de Windows</td>
      <td>✅ Funcionan</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">managementTemplates</code></td>
      <td>✅ Solo el catálogo</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">managementIntents</code>, <code class="language-plaintext highlighter-rouge">managementActions</code>, sus estados de despliegue</td>
      <td>❌ 404</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tenantGroups</code></td>
      <td>❌ 404</td>
    </tr>
  </tbody>
</table>

<p>Conclusión: por API, Lighthouse sirve para <strong>leer la postura de seguridad</strong>, no para aplicar configuraciones. Las baselines se despliegan desde el portal.</p>

<p>Además, Lighthouse solo gestiona por completo los tenants que cumplen sus requisitos: GDAP, al menos una suscripción de Microsoft 365 o similar, <strong>hasta 2.500 usuarios con licencia</strong> y misma región que el partner. El resto se queda en una experiencia limitada. En mi caso, la mayoría de tenants no eran aptos, y los de laboratorio tampoco, porque no tenían licencias de M365.</p>

<p>Y dos matices al leer los datos de MFA:</p>

<ul>
  <li>El informe de registro de MFA <strong>requiere Entra ID P1</strong> en el cliente. Un tenant Free que aparece con cero registros no significa que no tenga MFA: significa que <strong>no hay datos</strong>.</li>
  <li>El indicador de Lighthouse solo mira acceso condicional y <em>security defaults</em>. <strong>No detecta el MFA por usuario</strong> (el método heredado), que sí funciona sin licencia y se puede leer en Graph con <code class="language-plaintext highlighter-rouge">perUserMfaState</code>.</li>
</ul>

<hr />

<h2 id="fase-4-graph-con-gdap-desde-mi-propia-app">Fase 4: Graph con GDAP desde mi propia app</h2>

<p>El siguiente paso era actuar, no solo leer. Mi primera prueba con <em>Microsoft Graph Command Line Tools</em> contra un tenant cliente falló. La razón: con GDAP, la app que usa el técnico tiene que estar <strong>autorizada en el tenant del cliente</strong>.</p>

<p>La solución fue registrar en el tenant del partner una <strong>app multi-tenant, cliente público, solo con permisos delegados</strong>. La app no puede hacer nada por sí misma: todo queda limitado a los roles GDAP del técnico que inicia sesión.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$appId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="nx">create</span><span class="w"> </span><span class="nt">--display-name</span><span class="w"> </span><span class="s2">"app-l0agent-gdap-cli"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--sign-in-audience</span><span class="w"> </span><span class="nx">AzureADMultipleOrgs</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--public-client-redirect-uris</span><span class="w"> </span><span class="nx">http://localhost</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">--is-fallback-public-client</span><span class="w"> </span><span class="nx">true</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="nx">appId</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="w">
</span></code></pre></div></div>

<p>La app se consiente en cada cliente. En el laboratorio lo hice con la URL de <em>admin consent</em> y la cuenta de administrador del tenant; con clientes reales se hace con la API de consentimiento de Partner Center.</p>

<p>Y aquí el primer aprendizaje importante: <strong>cada permiso nuevo exige volver a consentir la app en cada cliente</strong>. Si no, aparece este error al iniciar sesión:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AADSTS90099: The application '&lt;app-id&gt;' has not been authorized in the tenant '&lt;tenant-id&gt;'.
Applications must be authorized to access the customer tenant before partner delegated administrators can use them.
</code></pre></div></div>

<p>Un técnico vía GDAP no puede conceder el consentimiento al vuelo como haría un usuario normal. Por eso conviene definir desde el principio el conjunto de permisos del catálogo completo.</p>

<p>Con la app consentida, el patrón es sencillo: me conecto con mi cuenta del partner, pero apuntando al tenant del cliente:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">Connect-MgGraph</span><span class="w"> </span><span class="nt">-ClientId</span><span class="w"> </span><span class="nv">$appId</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nv">$customerTenantId</span><span class="w"> </span><span class="nt">-Scopes</span><span class="w"> </span><span class="s2">"User.Read.All"</span><span class="w">
</span><span class="n">Get-MgContext</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">Account</span><span class="p">,</span><span class="w"> </span><span class="nx">TenantId</span><span class="w">   </span><span class="c"># mi cuenta del partner, en el tenant del cliente</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="fase-5-las-primeras-tareas-reales">Fase 5: las primeras tareas reales</h2>

<h3 id="cambio-de-contraseña">Cambio de contraseña</h3>

<p>La más sencilla: un <code class="language-plaintext highlighter-rouge">PATCH</code> sobre el usuario con <code class="language-plaintext highlighter-rouge">passwordProfile</code> y <code class="language-plaintext highlighter-rouge">forceChangePasswordNextSignIn</code>. Funcionó a la primera, y en los registros de auditoría del cliente aparece mi cuenta del partner como autora del cambio.</p>

<h3 id="móvil-nuevo-reseteo-de-mfa">Móvil nuevo: reseteo de MFA</h3>

<p>Es uno de los tickets más habituales. El flujo que buscaba: borrar los métodos MFA antiguos, emitir un <strong>Temporary Access Pass (TAP)</strong> de un solo uso y cerrar las sesiones, para que el usuario registre Authenticator en el móvil nuevo.</p>

<p>La primera ejecución falló con este error:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Cannot delete default method with other methods configured. Please change default method before deletion.
</code></pre></div></div>

<p>La API <strong>no deja borrar el método predeterminado mientras queden otros</strong>. La solución fue borrar en varias pasadas: primero lo que se puede, y el predeterminado al final, cuando ya es el último.</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">for</span><span class="w"> </span><span class="p">(</span><span class="nv">$pass</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span><span class="w"> </span><span class="nv">$pass</span><span class="w"> </span><span class="o">-le</span><span class="w"> </span><span class="mi">4</span><span class="p">;</span><span class="w"> </span><span class="nv">$pass</span><span class="o">++</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="n">Start-Sleep</span><span class="w"> </span><span class="nt">-Seconds</span><span class="w"> </span><span class="nx">5</span><span class="w">   </span><span class="c"># margen para la replicación</span><span class="w">
  </span><span class="nv">$pending</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@(</span><span class="err">Get-AuthMethods</span><span class="w"> </span><span class="err">|</span><span class="w"> </span><span class="err">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nv">$segment</span><span class="o">.</span><span class="nf">ContainsKey</span><span class="p">(</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Tipo</span><span class="p">)</span><span class="w"> </span><span class="p">})</span><span class="w">
  </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$pending</span><span class="o">.</span><span class="nf">Count</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="kr">break</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$m</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$pending</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">try</span><span class="w">   </span><span class="p">{</span><span class="w"> </span><span class="n">Invoke-MgGraphRequest</span><span class="w"> </span><span class="nx">DELETE</span><span class="w"> </span><span class="s2">"</span><span class="nv">$base</span><span class="s2">/</span><span class="si">$(</span><span class="nv">$segment</span><span class="p">[</span><span class="nv">$m</span><span class="o">.</span><span class="nf">Tipo</span><span class="p">]</span><span class="si">)</span><span class="s2">/</span><span class="si">$(</span><span class="nv">$m</span><span class="o">.</span><span class="nf">id</span><span class="si">)</span><span class="s2">"</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">Stop</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="p">}</span><span class="w">   </span><span class="c"># 400: es el predeterminado, siguiente pasada · 404: ya borrado</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Esa misma prueba destapó otro comportamiento: <strong>la consistencia eventual</strong>. Un teléfono recién añadido no aparecía en el siguiente listado, y un método recién borrado seguía apareciendo unos segundos después. Cualquier automatización tiene que reintentar y <strong>verificar el estado final</strong>, no fiarse de la primera respuesta, y distinguir un 404 (ya hecho) de un 400 (regla de negocio).</p>

<hr />

<h2 id="fase-6-de-ejecutar-tareas-a-autorremediar">Fase 6: de ejecutar tareas a autorremediar</h2>

<p>Con las piezas probadas, cambié el enfoque: no solo atender lo que pide el usuario, sino <strong>detectar la necesidad del cliente y corregirla sola</strong>. Monté un pequeño motor de reglas, cada una con el ciclo <em>detectar → remediar → verificar → dejar evidencia</em>:</p>

<table>
  <thead>
    <tr>
      <th>Regla</th>
      <th>Necesidad del cliente</th>
      <th>Acción</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Bajas</td>
      <td>RR. HH. mete a un usuario en el grupo de bajas</td>
      <td>Corrige sola: bloquea, cierra sesiones, borra MFA</td>
    </tr>
    <tr>
      <td>Contraseñas</td>
      <td>Cuentas con contraseña que nunca caduca</td>
      <td>Corrige sola</td>
    </tr>
    <tr>
      <td>MFA</td>
      <td>Usuarios sin ningún método MFA</td>
      <td>Informa</td>
    </tr>
    <tr>
      <td>Privilegiados</td>
      <td>Administradores sin MFA o exceso de Global Admin</td>
      <td>Informa y pide aprobación</td>
    </tr>
    <tr>
      <td>Aplicaciones</td>
      <td>Secretos o certificados a punto de caducar</td>
      <td>Informa con antelación</td>
    </tr>
  </tbody>
</table>

<p>Con modo informe, simulación (<code class="language-plaintext highlighter-rouge">-WhatIf</code>) y remediación, y tres salvaguardas que no se saltan nunca: <strong>no actuar sobre usuarios con roles de administrador, ni sobre un grupo de exclusión, ni sobre usuarios sincronizados desde AD local</strong> (su contraseña vive en el AD, no en la nube).</p>

<hr />

<h2 id="fase-7-el-portal-para-el-cliente">Fase 7: el portal para el cliente</h2>

<p>Lo que buscaba al final era que el cliente pudiera pedirlo <strong>sin abrir un ticket</strong>. La primera versión del portal tenía un fallo de concepto: todo ocurría con identidades del partner. Yo pedía, yo aprobaba y yo ejecutaba. Al enseñárselo a mi jefe la pregunta fue obvia: <em>“¿y el cliente dónde está?”</em>.</p>

<p>La versión buena separa tres identidades:</p>

<pre><code class="language-mermaid">flowchart LR
    A[Usuario del cliente&lt;br/&gt;inicia sesión con su cuenta&lt;br/&gt;y pide la tarea] --&gt; B[Responsable del cliente&lt;br/&gt;recibe el correo&lt;br/&gt;y aprueba]
    B --&gt; C[Portal del partner&lt;br/&gt;comprueba usuario, roles&lt;br/&gt;y aislamiento]
    C --&gt;|GDAP| D[Tenant del cliente&lt;br/&gt;Graph aplica el cambio&lt;br/&gt;y queda auditado]
    C -.-&gt;|contraseña temporal o TAP| B
</code></pre>

<ul>
  <li><strong>El cliente pide y aprueba con su propia cuenta</strong>, mediante una segunda app que solo tiene <code class="language-plaintext highlighter-rouge">User.Read</code>. Nunca toca la app que ejecuta.</li>
  <li><strong>El partner ejecuta</strong> con su identidad GDAP. En la demo, mi sesión iniciada una vez al arrancar; en producción, una cuenta de servicio con su propio grupo GDAP de roles mínimos y su token protegido en Key Vault, que es el patrón que Microsoft documenta para aplicaciones de partners.</li>
  <li><strong>Los responsables se eligen según el usuario afectado</strong>: lista explícita para ese usuario, después su departamento, después su manager en Entra ID y, si no hay ninguno, los responsables por defecto. Se avisa a todos y basta con que apruebe uno.</li>
</ul>

<p>Y las reglas de seguridad que considero no negociables:</p>

<ul>
  <li><strong>El cliente se deduce del tenant de quien inicia sesión</strong>, nunca de un desplegable. Un usuario solo puede pedir tareas sobre usuarios de su propia organización.</li>
  <li><strong>Solo entran los tenants dados de alta</strong>: una app multi-tenant admite, en principio, cualquier cuenta de Entra ID.</li>
  <li><strong>Nadie aprueba su propia solicitud</strong> ni una que le afecte.</li>
  <li><strong>El enlace del correo no aprueba nada por sí mismo</strong>: abre una página de revisión con los botones. Así los escáneres de enlaces del correo no pueden aprobar solicitudes al abrir el mensaje.</li>
  <li><strong>La contraseña temporal o el TAP solo llegan al responsable que aprueba.</strong> No se guardan ni se muestran en pantalla.</li>
</ul>

<p>El resultado: la usuaria pide el cambio de contraseña, al responsable le llega el correo, lo aprueba, recibe la contraseña temporal y, cuando la usuaria entra, Entra ID le obliga a cambiarla. Con el reseteo de MFA, recibe el TAP y registra Authenticator en el móvil nuevo. Todo sin que intervenga un técnico.</p>

<hr />

<h2 id="lo-que-aprendí-por-el-camino">Lo que aprendí por el camino</h2>

<p>Los errores, otra vez, fueron lo más instructivo:</p>

<ol>
  <li><strong>Un token obtenido de la caché no trae los datos de identidad.</strong> Al aprobar, MSAL reutilizaba la sesión anterior sin <code class="language-plaintext highlighter-rouge">id_token_claims</code>, el portal no sabía quién era el aprobador y lo rechazaba. La identidad hay que tomarla de la cuenta en caché.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">127.0.0.1</code> y <code class="language-plaintext highlighter-rouge">localhost</code> no son lo mismo para el navegador.</strong> Flask anuncia una dirección, Entra ID devuelve el inicio de sesión a la otra, la cookie de sesión no viaja y el login falla con un 400. Ahora el portal fuerza siempre la misma dirección.</li>
  <li><strong>Un ID de ejemplo en la configuración</strong> (<code class="language-plaintext highlighter-rouge">&lt;APP_ID_DEL_FRONT&gt;</code>) produce un <code class="language-plaintext highlighter-rouge">AADSTS90013</code> poco descriptivo. Validar la configuración al arrancar ahorra tiempo.</li>
  <li><strong>La demo se rompe si la usuaria y el responsable usan la misma ventana.</strong> El portal ve a la usuaria intentando aprobar y, con razón, la rechaza. Añadí un botón de “Cambiar de cuenta” y, en las demos, siempre ventanas privadas distintas.</li>
  <li><strong>Un tenant de laboratorio sin licencias no tiene buzones.</strong> Los responsables necesitan un correo de aviso distinto de su cuenta de inicio de sesión.</li>
  <li><strong>El cambio de contraseña del propio usuario ya lo resuelve Microsoft con SSPR.</strong> Es la mejor tarea para una demo porque se entiende al instante, pero el valor del producto está en lo que SSPR no cubre: aprobaciones, móvil nuevo, altas y bajas, o un compañero que pide por quien no puede iniciar sesión.</li>
</ol>

<hr />

<h2 id="lo-que-queda">Lo que queda</h2>

<p>La demo funciona en mi equipo. Para que sea un servicio faltan tres cosas: la <strong>cuenta de servicio</strong> con su grupo GDAP de roles mínimos, el <strong>despliegue en Azure</strong> (Container Apps, Key Vault y una base de datos, con Terraform) y <strong>ordenar los accesos GDAP</strong> antes del primer cliente piloto.</p>

<p>Después, ampliar el catálogo. Con los roles GDAP habituales de un partner hay más de veinticinco tareas de Entra ID posibles: altas y bajas, licencias, grupos, invitados, inventario de privilegiados, secretos de aplicaciones a punto de caducar. Y con licencias de laboratorio, el salto a Exchange, Teams, SharePoint e Intune.</p>

<hr />

<h2 id="reflexión-final">Reflexión final</h2>

<p>Lo que más me ha enseñado este trabajo es que <strong>el problema no era técnico, era de modelo de identidad</strong>. Graph, GDAP y Lighthouse ya estaban ahí. Lo difícil fue decidir quién pide, quién aprueba y quién ejecuta, y que cada una de esas identidades tenga solo los permisos que necesita.</p>

<p>La automatización ejecuta la tarea en segundos. Pero la aprobación sigue siendo humana, y la responsabilidad sobre los accesos al tenant de un cliente también.</p>

<hr />

<p><em>Si tienes preguntas sobre el modelo o quieres los scripts, puedes contactarme en <a href="mailto:luisadanmunoz@outlook.com">luisadanmunoz@outlook.com</a>.</em></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Azure" /><category term="Seguridad" /><category term="Automatizacion" /><category term="entra-id" /><category term="gdap" /><category term="microsoft-365-lighthouse" /><category term="microsoft-graph" /><category term="csp" /><category term="cost-management" /><category term="python" /><category term="powershell" /><category term="autorremediacion" /><summary type="html"><![CDATA[Trabajo en un partner CSP de Microsoft y hay dos quejas que se repiten en casi todos los clientes: “no veo lo que me cuesta Azure” y “para resetear una contraseña tengo que abrir un ticket y esperar”. Las dos tienen solución con herramientas que Microsoft ya da a los partners, pero nadie las había juntado.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Un segundo cerebro en Obsidian para un consultor de Azure</title><link href="https://luisadanmunoz.es/posts/segundo-cerebro-obsidian-consultor-azure/" rel="alternate" type="text/html" title="Un segundo cerebro en Obsidian para un consultor de Azure" /><published>2026-09-23T00:00:00+02:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/segundo-cerebro-obsidian-consultor-azure</id><content type="html" xml:base="https://luisadanmunoz.es/posts/segundo-cerebro-obsidian-consultor-azure/"><![CDATA[<p>Como consultor Azure, mi conocimiento estaba repartido por todas partes: ofertas en carpetas locales, chuletas de comandos en ficheros sueltos, apuntes de certificaciones en PDFs, módulos de Terraform en repos, y decenas de “esto ya lo resolví una vez, pero ¿dónde?”.</p>

<p>Decidí ponerle orden con <strong>Obsidian</strong>, pero sin quedarme en “instalo plugins y a ver qué pasa”. Quería un sistema enterprise: versionado, con guardarraíles de seguridad, convenciones estrictas y conectado a <strong>Claude Code</strong> para que la IA pudiera consultar y escribir en él con las mismas reglas que yo.</p>

<p>Esta es la guía de cómo lo monté paso a paso, con las decisiones que tomé y, sobre todo, con los errores que cometí por el camino.</p>

<hr />

<h2 id="la-regla-que-me-impuse-no-pasar-de-paso-sin-validar-el-anterior">La regla que me impuse: no pasar de paso sin validar el anterior</h2>

<p>El plan final tuvo 35 pasos agrupados en 8 fases. La disciplina fue sencilla: cada paso terminaba con una <strong>prueba verificable</strong> (un comando, una nota de prueba, una captura) y no avanzaba hasta que salía bien.</p>

<p>Suena lento, pero me ahorró horas. Varios de los problemas que cuento al final los detecté justo porque la prueba del paso falló.</p>

<hr />

<h2 id="fase-0-las-decisiones-que-condicionan-todo">Fase 0: las decisiones que condicionan todo</h2>

<p>Antes de crear una sola carpeta tuve que decidir cuatro cosas. Y reconozco que cambié de opinión en la más importante.</p>

<p><strong>Uno o dos vaults.</strong> Empecé diseñando dos vaults: uno “conectable” (Azure, IA, SysOps) enlazado a Claude y GitHub, y otro “cerrado”, solo local, para el material de clientes. Al avanzar vi que la separación me costaba más de lo que me aportaba, y acabé en <strong>un vault único</strong>, versionado en un repo privado de GitHub y conectado a Claude.</p>

<p>Si te planteas lo mismo, mi consejo es no decidirlo a la ligera: revisa qué dicen tus contratos y la política de tu empresa sobre almacenar datos de cliente en GitHub y procesarlos con IA. Esa decisión es tuya, no de la herramienta.</p>

<p><strong>Binarios fuera de Git.</strong> Los <code class="language-plaintext highlighter-rouge">pptx</code>, <code class="language-plaintext highlighter-rouge">xlsx</code>, <code class="language-plaintext highlighter-rouge">docx</code>, <code class="language-plaintext highlighter-rouge">pdf</code> y <code class="language-plaintext highlighter-rouge">zip</code> no se versionan. Git está pensado para texto, y los binarios hacen crecer el repo sin aportar nada útil al historial. Los protejo con una copia de seguridad aparte.</p>

<p><strong>Repos de código fuera del vault.</strong> Probé a meter los repos dentro de las carpetas de cliente, con su propio <code class="language-plaintext highlighter-rouge">.git</code>, y acabé sacándolos: cada repo vive en su sitio y desde las notas enlazo a su URL de GitHub.</p>

<p><strong>Motor de consultas: Bases + Tasks.</strong> Bases es un plugin core de Obsidian: no añade código de terceros y trabaja directamente sobre las propiedades del frontmatter, que es donde está toda mi información estructurada. Tasks cubre lo que Bases no hace bien, que son las tareas sueltas dentro de las notas.</p>

<hr />

<h2 id="fase-1-cimientos">Fase 1: cimientos</h2>

<h3 id="estructura-de-carpetas">Estructura de carpetas</h3>

<p>Tras varias iteraciones, la estructura quedó así:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Vault-Consultoria/
├── 00-SysOps/          # Chuletas: AzCli, KQL, Docker, Kubernetes, Git, Terraform, PowerShell
├── 01-Azure/
│   ├── 01-Recursos/    # Un servicio de Azure por nota, agrupados por categoría
│   └── 02-Certificaciones/   # Una carpeta por certificación con sus apuntes
├── 02-Terraform/       # Un recurso azurerm por nota, mismas categorías
├── 03-IA/              # 01-MVP · 02-Skills · 03-Casos-de-Uso
├── 04-Clientes/&lt;Cliente&gt;/   # 01-Ofertas · 02-Proyectos · 03-Documentacion-Cliente · 04-Reuniones
├── 98-Documentos/      # Web-Clipper, Diagramas, Iconos-Azure
└── 99-Plantillas/      # Convenciones, registro de plugins, plantillas de Templater
</code></pre></div></div>

<p>Dos lecciones de diseño:</p>

<ul>
  <li><strong>Todo con guiones, sin tildes ni espacios.</strong> Las rutas pasan por Git, PowerShell y Claude Code, y cada carácter raro es un problema potencial.</li>
  <li><strong>Numeración correlativa.</strong> Parece una manía, pero cuando quité una subcarpeta de cliente y quedó un hueco en la numeración, preferí renumerar. Eso me obligó a actualizar el router de plantillas y las convenciones, y fue la mejor prueba de que todo estaba bien acoplado.</li>
</ul>

<p>Toda la estructura se crea con un script de PowerShell <strong>idempotente</strong>: lo puedo relanzar para añadir un cliente o una certificación sin tocar lo que ya existe.</p>

<h3 id="git--gitleaks-la-red-de-seguridad">Git + Gitleaks: la red de seguridad</h3>

<p>Si el vault se sube a GitHub, un secreto pegado por descuido en una nota acaba en internet. Por eso el primer guardarraíl fue un <strong>hook pre-commit con Gitleaks</strong>, que bloquea cualquier commit que contenga algo con aspecto de credencial:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$hook</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"#!/bin/sh</span><span class="se">`n</span><span class="s2">gitleaks git --pre-commit --staged --redact -v</span><span class="se">`n</span><span class="s2">"</span><span class="w">
</span><span class="p">[</span><span class="n">IO.File</span><span class="p">]::</span><span class="n">WriteAllText</span><span class="p">(</span><span class="s2">"</span><span class="bp">$PWD</span><span class="s2">\.git\hooks\pre-commit"</span><span class="p">,</span><span class="w"> </span><span class="nv">$hook</span><span class="p">)</span><span class="w">
</span></code></pre></div></div>

<p>Lo probé con un token falso con formato de GitHub, y Gitleaks lo detectó, lo tapó (<code class="language-plaintext highlighter-rouge">REDACTED</code>) y abortó el commit. Justo lo que quería.</p>

<p>Y al día siguiente me bloqueó a mí. Al subir la carpeta <code class="language-plaintext highlighter-rouge">.obsidian/plugins</code>, Gitleaks encontró “API keys” en el <code class="language-plaintext highlighter-rouge">main.js</code> minificado de un plugin: eran falsos positivos. La solución no fue silenciar a Gitleaks, sino <strong>no versionar el código de los plugins</strong>, que se reinstala desde la tienda, y quedarme solo con su configuración:</p>

<pre><code class="language-gitignore">.obsidian/plugins/*/*
!.obsidian/plugins/*/manifest.json
!.obsidian/plugins/*/data.json
</code></pre>

<p>El repo pasó de 22 MB a unos pocos KB, y Gitleaks sigue vigilando los <code class="language-plaintext highlighter-rouge">data.json</code>, que es donde de verdad podría colarse un secreto.</p>

<p>Otras exclusiones que no deberían faltar en ningún vault con código: <code class="language-plaintext highlighter-rouge">*.tfstate</code>, <code class="language-plaintext highlighter-rouge">*.tfplan</code>, <code class="language-plaintext highlighter-rouge">.terraform/</code>, <code class="language-plaintext highlighter-rouge">*.env</code>, <code class="language-plaintext highlighter-rouge">*.pfx</code>, <code class="language-plaintext highlighter-rouge">*.pem</code> y <code class="language-plaintext highlighter-rouge">*.key</code>. El state de Terraform puede contener secretos en claro.</p>

<hr />

<h2 id="fase-2-convenciones-antes-que-contenido">Fase 2: convenciones antes que contenido</h2>

<p>Este es el paso que más me costó y el que más valor aporta: <strong>definir el esquema antes de escribir una sola nota</strong>. Si cargas contenido primero, luego te toca rehacer el frontmatter a mano.</p>

<p>Definí 14 tipos de nota, cada uno con sus campos y sus estados válidos. Un extracto:</p>

<table>
  <thead>
    <tr>
      <th>tipo</th>
      <th>Campos propios</th>
      <th>Estados</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>chuleta</td>
      <td>herramienta, verificado, fuente</td>
      <td>borrador · revisado</td>
    </tr>
    <tr>
      <td>recurso-azure</td>
      <td>categoria, servicio, verificado, fuente</td>
      <td>borrador · revisado</td>
    </tr>
    <tr>
      <td>modulo-terraform</td>
      <td>categoria, recurso, provider-version, repo</td>
      <td>borrador · probado · produccion</td>
    </tr>
    <tr>
      <td>oferta</td>
      <td>cliente, fecha-envio</td>
      <td>borrador · enviada · ganada · perdida</td>
    </tr>
    <tr>
      <td>apunte</td>
      <td>certificacion, dominio</td>
      <td>borrador · repasado</td>
    </tr>
  </tbody>
</table>

<p>Los campos <code class="language-plaintext highlighter-rouge">verificado</code> y <code class="language-plaintext highlighter-rouge">fuente</code> son los que más aprecio. Azure cambia constantemente, así que cada nota técnica registra <strong>con qué URL oficial y en qué fecha</strong> se validó. Una vez al mes reviso las notas con más de seis meses.</p>

<p>Completé el esquema con dos reglas más:</p>

<ul>
  <li><strong>Nombres:</strong> <code class="language-plaintext highlighter-rouge">Git - Deshacer ultimo commit</code>, <code class="language-plaintext highlighter-rouge">azurerm_key_vault</code>, <code class="language-plaintext highlighter-rouge">2026-09-23 Oferta Cliente Landing Zone</code>. La fecha va delante en todo lo que ocurre en un momento concreto.</li>
  <li><strong>Tags en lista cerrada</strong>, agrupados por pilares WAF, metodologías CAF, herramienta e IA. Un tag nuevo solo existe si antes lo añado a la tabla. Así evito tener <code class="language-plaintext highlighter-rouge">#terraform</code>, <code class="language-plaintext highlighter-rouge">#Terraform</code> y <code class="language-plaintext highlighter-rouge">#tf</code> conviviendo.</li>
</ul>

<p>Todo vive en una nota, <code class="language-plaintext highlighter-rouge">99-Plantillas/Convenciones.md</code>, que es la fuente de verdad para mí y, como veremos, también para Claude.</p>

<hr />

<h2 id="fase-3-plugins-y-plantillas">Fase 3: plugins y plantillas</h2>

<h3 id="pocos-plugins-y-con-registro">Pocos plugins y con registro</h3>

<p>Instalé 14 plugins de la comunidad. Todos son código de terceros con acceso completo al vault, así que los gobierno como gobernaría dependencias en un proyecto:</p>

<ul>
  <li>Un script genera <code class="language-plaintext highlighter-rouge">Registro-Plugins.md</code> leyendo el <code class="language-plaintext highlighter-rouge">manifest.json</code> de cada plugin: nombre, versión, autor y para qué lo uso.</li>
  <li>Nunca pulso “Actualizar todo”. Actualizo de uno en uno, revisando antes el changelog.</li>
  <li>Antes de instalar uno, reviso sus descargas y su fecha de última actualización.</li>
</ul>

<h3 id="el-router-de-templater-la-pieza-clave">El router de Templater: la pieza clave</h3>

<p>Templater permite asignar una plantilla por carpeta, pero no admite comodines. Eso choca con rutas como <code class="language-plaintext highlighter-rouge">04-Clientes/&lt;cualquier cliente&gt;/01-Ofertas</code>.</p>

<p>La solución fue <strong>una única plantilla “router” asignada a todo el vault</strong>, que mira dónde se ha creado la nota y aplica la plantilla que le corresponde:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&lt;%*</span>
<span class="kd">const</span> <span class="nx">p</span> <span class="o">=</span> <span class="nx">tp</span><span class="p">.</span><span class="nx">file</span><span class="p">.</span><span class="nf">folder</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">s</span> <span class="o">=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">let</span> <span class="nx">t</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">s</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">00-SysOps</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="mi">2</span><span class="p">)</span> <span class="nx">t</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">chuleta</span><span class="dl">"</span><span class="p">;</span>
<span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">01-Azure/01-Recursos/</span><span class="dl">"</span><span class="p">))</span> <span class="nx">t</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">recurso-azure</span><span class="dl">"</span><span class="p">;</span>
<span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">01-Azure/02-Certificaciones/</span><span class="dl">"</span><span class="p">))</span> <span class="nx">t</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">apunte</span><span class="dl">"</span><span class="p">;</span>
<span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">s</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">02-Terraform</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">length</span> <span class="o">&gt;=</span> <span class="mi">2</span><span class="p">)</span> <span class="nx">t</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">modulo-terraform</span><span class="dl">"</span><span class="p">;</span>
<span class="k">else</span> <span class="k">if </span><span class="p">(</span><span class="nx">s</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">04-Clientes</span><span class="dl">"</span> <span class="o">&amp;&amp;</span> <span class="nx">s</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">01-Ofertas</span><span class="dl">"</span><span class="p">)</span> <span class="nx">t</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">oferta</span><span class="dl">"</span><span class="p">;</span>
<span class="c1">// ...resto de tipos</span>
<span class="k">if </span><span class="p">(</span><span class="nx">tp</span><span class="p">.</span><span class="nx">file</span><span class="p">.</span><span class="nx">title</span><span class="p">.</span><span class="nf">endsWith</span><span class="p">(</span><span class="dl">"</span><span class="s2">.excalidraw</span><span class="dl">"</span><span class="p">))</span> <span class="nx">t</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">t</span><span class="p">)</span> <span class="p">{</span> <span class="nx">tR</span> <span class="o">+=</span> <span class="k">await</span> <span class="nx">tp</span><span class="p">.</span><span class="nx">file</span><span class="p">.</span><span class="nf">include</span><span class="p">(</span><span class="dl">"</span><span class="s2">[[99-Plantillas/Templater/T-</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">t</span> <span class="o">+</span> <span class="dl">"</span><span class="s2">]]</span><span class="dl">"</span><span class="p">);</span> <span class="p">}</span>
<span class="o">-%&gt;</span>
</code></pre></div></div>

<p>Además, cada plantilla <strong>deduce campos de la ruta</strong>. Si creo una nota en <code class="language-plaintext highlighter-rouge">00-SysOps/05-Git</code>, aparece sola con <code class="language-plaintext highlighter-rouge">tipo: chuleta</code> y <code class="language-plaintext highlighter-rouge">herramienta: Git</code>. Si la creo en la carpeta de una certificación, sale con <code class="language-plaintext highlighter-rouge">tipo: apunte</code> y el código de la certificación ya relleno.</p>

<p>La línea de <code class="language-plaintext highlighter-rouge">.excalidraw</code> la añadí más tarde, al darme cuenta de que el router aplicaría la plantilla de documento dentro de los ficheros de Excalidraw y los rompería.</p>

<h3 id="linter-git-automático-y-web-clipper">Linter, Git automático y Web Clipper</h3>

<ul>
  <li><strong>Linter</strong>, con solo dos reglas: mantener <code class="language-plaintext highlighter-rouge">actualizado</code> al día y normalizar los tags. Con la carpeta de plantillas excluida, porque si no “corrige” los bloques <code class="language-plaintext highlighter-rouge">&lt;% %&gt;</code> de Templater.</li>
  <li><strong>Plugin Git:</strong> commit y push automáticos 15 minutos después de dejar de editar. El hook de Gitleaks también se ejecuta en esos commits.</li>
  <li><strong>Obsidian Web Clipper</strong> en Chrome, apuntando a <code class="language-plaintext highlighter-rouge">98-Documentos/Web-Clipper</code> con propiedades alineadas con mi esquema. Capturo documentación de Microsoft Learn con un clic y luego la muevo a su sitio.</li>
</ul>

<hr />

<h2 id="fase-5-explotación">Fase 5: explotación</h2>

<h3 id="dashboard-con-bases">Dashboard con Bases</h3>

<p>Una nota <code class="language-plaintext highlighter-rouge">Inicio</code> se abre al arrancar Obsidian con vistas de Bases incrustadas: ofertas abiertas agrupadas por cliente, proyectos en curso, MVP de IA activos, certificaciones en curso y todo lo que está en borrador pendiente de revisar.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">filters</span><span class="pi">:</span>
  <span class="na">and</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s1">'</span><span class="s">tipo</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">"oferta"'</span>
    <span class="pi">-</span> <span class="na">or</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s1">'</span><span class="s">estado</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">"borrador"'</span>
        <span class="pi">-</span> <span class="s1">'</span><span class="s">estado</span><span class="nv"> </span><span class="s">==</span><span class="nv"> </span><span class="s">"enviada"'</span>
<span class="na">views</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">table</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Ofertas abiertas</span>
    <span class="na">groupBy</span><span class="pi">:</span>
      <span class="na">property</span><span class="pi">:</span> <span class="s">cliente</span>
      <span class="na">direction</span><span class="pi">:</span> <span class="s">ASC</span>
</code></pre></div></div>

<p>Como todo se alimenta del frontmatter, el dashboard nunca se desincroniza.</p>

<h3 id="por-qué-no-usé-kanban-para-las-ofertas">Por qué no usé Kanban para las ofertas</h3>

<p>Pensé en un tablero Kanban de ofertas, pero lo descarté: mover una tarjeta de “Enviada” a “Ganada” <strong>no cambia el <code class="language-plaintext highlighter-rouge">estado</code></strong> de la nota de la oferta. Tendría el estado duplicado en dos sitios, y tarde o temprano el tablero y el dashboard dirían cosas distintas. Kanban quedó solo como tablero de trabajo personal, sin ligarlo a propiedades.</p>

<h3 id="flashcards-diagramas-y-enlaces">Flashcards, diagramas y enlaces</h3>

<ul>
  <li><strong>Spaced Repetition:</strong> cada apunte de certificación lleva una sección de flashcards, y cada certificación se convierte automáticamente en un mazo.</li>
  <li><strong>Excalidraw con los iconos oficiales de Azure</strong>, descargados del Azure Architecture Center. Microsoft permite usarlos en diagramas de arquitectura, formación y documentación. Los excluyo de Git porque se pueden volver a descargar.</li>
  <li><strong>Sección <code class="language-plaintext highlighter-rouge">## Relacionado</code></strong> en las plantillas, para enlazar un recurso con su módulo Terraform, sus chuletas y los proyectos donde lo usé. El panel de enlaces entrantes hace el resto.</li>
</ul>

<hr />

<h2 id="fase-6-claude-code-como-coautor-del-vault">Fase 6: Claude Code como coautor del vault</h2>

<p>Esta era la parte que más me interesaba. Descarté la vía del plugin Local REST API con MCP: <strong>Claude Code trabaja directamente sobre los ficheros</strong>, así que no necesitaba ni plugin ni puerto abierto ni API key.</p>

<h3 id="claudemd-las-reglas-de-la-casa"><code class="language-plaintext highlighter-rouge">CLAUDE.md</code>: las reglas de la casa</h3>

<p>En la raíz del vault hay un <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> que Claude Code carga en cada sesión. Resumido:</p>

<ul>
  <li>Leer <code class="language-plaintext highlighter-rouge">Convenciones.md</code> antes de crear o editar nada.</li>
  <li>Ubicar cada nota según su tipo, usar la plantilla correspondiente sustituyendo las expresiones de Templater y <strong>escribir la nota completa de una vez</strong>. Si creara un fichero vacío, Templater le aplicaría su plantilla encima.</li>
  <li><strong>Rigor técnico:</strong> toda afirmación sobre Azure, respaldada por Microsoft Learn, con <code class="language-plaintext highlighter-rouge">fuente</code> y <code class="language-plaintext highlighter-rouge">verificado</code> rellenos. Si no puede verificar algo, lo dice y añade el tag <code class="language-plaintext highlighter-rouge">revisar</code>. Nada de inventar límites, SKUs ni precios.</li>
  <li><strong>Prohibido sin confirmación:</strong> borrar o mover notas, tocar la configuración, hacer commits y mezclar información entre clientes.</li>
</ul>

<h3 id="permisos-defensa-en-profundidad">Permisos: defensa en profundidad</h3>

<p>El <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> son instrucciones. Los permisos son la segunda capa:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"permissions"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"deny"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"Edit(./.obsidian/**)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Edit(./.git/**)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Edit(./99-Plantillas/**)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Bash(git commit:*)"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Bash(git push:*)"</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"ask"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Bash(Remove-Item:*)"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Bash(Move-Item:*)"</span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Cuando probé a pedirle “haz commit y push”, Claude Code lo intentó y <strong>el permiso lo bloqueó</strong>. Aun así, no trato estas reglas como una barrera infalible: la red de seguridad real sigue siendo Git más Gitleaks.</p>

<h3 id="mcp-de-microsoft-learn">MCP de Microsoft Learn</h3>

<p>Para que la regla de “verificar contra Microsoft Learn” tenga una fuente real detrás, conecté el MCP oficial a nivel de proyecto:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">claude</span><span class="w"> </span><span class="nx">mcp</span><span class="w"> </span><span class="nx">add</span><span class="w"> </span><span class="nt">--transport</span><span class="w"> </span><span class="nx">http</span><span class="w"> </span><span class="nt">--scope</span><span class="w"> </span><span class="nx">project</span><span class="w"> </span><span class="nx">microsoft-learn</span><span class="w"> </span><span class="nx">https://learn.microsoft.com/api/mcp</span><span class="w">
</span></code></pre></div></div>

<p>Un detalle de seguridad: Claude Code también cargó mis conectores de claude.ai (correo, calendario, Drive). Los desactivé para este proyecto. No quiero que el contenido de mi correo acabe volcado en una nota que se sube a GitHub.</p>

<h3 id="la-prueba-de-fuego">La prueba de fuego</h3>

<p>Le pedí: <em>“Crea la nota del recurso Azure Key Vault verificando los datos en Microsoft Learn”</em>. Buscó en la documentación oficial, creó la nota en <code class="language-plaintext highlighter-rouge">01-Azure/01-Recursos/Security</code> con el frontmatter completo, la fuente enlazada y la fecha de verificación. Días después, al preguntarle por los límites de Key Vault, <strong>respondió desde mi propia nota</strong>, citando la fuente, sin volver a buscar en internet.</p>

<p>Eso es exactamente lo que quería: un segundo cerebro que se alimenta de fuentes oficiales y al que puedo consultar en lenguaje natural.</p>

<hr />

<h2 id="fase-7-mantenimiento">Fase 7: mantenimiento</h2>

<p>Un sistema sin mantenimiento se degrada, así que lo automaticé con <strong>tareas recurrentes de Tasks</strong> que aparecen solas en el dashboard:</p>

<ul>
  <li><strong>Cada viernes:</strong> vaciar las capturas del Web Clipper, actualizar los estados de ofertas y proyectos, lanzar la copia de binarios y comprobar que Git está limpio.</li>
  <li><strong>El día 1 de cada mes:</strong> revisar borradores, reverificar notas antiguas, revisar plugins y tags, y archivar clientes inactivos.</li>
</ul>

<p>La copia de binarios es un script con <code class="language-plaintext highlighter-rouge">robocopy</code> que <strong>nunca borra en destino</strong>: si un fichero desaparece del portátil por error, la copia lo conserva. La validé como se debe validar una copia de seguridad, restaurando un fichero y comparando su hash con el original. Una copia que no se ha restaurado nunca no es una copia.</p>

<hr />

<h2 id="lo-que-aprendí-por-el-camino">Lo que aprendí por el camino</h2>

<p>Los errores fueron lo más instructivo:</p>

<ol>
  <li><strong>Algunos ajustes de Obsidian no viven en <code class="language-plaintext highlighter-rouge">data.json</code>.</strong> El disparador de Templater al crear notas se guarda por dispositivo, así que mi script de configuración no podía activarlo. Tuve que hacerlo a mano.</li>
  <li><strong>Una regla de Linter configurada no sirve si su interruptor principal está apagado.</strong> La prueba del paso lo destapó en segundos.</li>
  <li><strong>Hacer clic en un enlace a una nota que no existe la crea en la carpeta actual</strong>, con la plantilla equivocada. Ahora mi regla es: primero creo la nota en su sitio y después la enlazo.</li>
  <li><strong>Pegar un bloque de prueba dentro del propio script</strong> crea un bucle infinito: el script se llama a sí mismo. Me pasó, y me hizo pensar que la unidad de red estaba colgada.</li>
  <li><strong>Las unidades de red mapeadas no existen en todas las sesiones.</strong> Uso siempre la ruta UNC en los scripts.</li>
  <li><strong>PowerShell 5.1 y la codificación UTF-8.</strong> Las tildes rotas y la marca BOM en un JSON me recordaron que para estos scripts hay que usar PowerShell 7.</li>
</ol>

<hr />

<h2 id="reflexión-final">Reflexión final</h2>

<p>Lo que más valor me ha dado no ha sido ningún plugin, sino <strong>decidir las convenciones antes del contenido</strong> y <strong>validar cada paso antes de avanzar</strong>. Con un esquema claro, las plantillas, el dashboard y Claude trabajan sobre la misma estructura y no se contradicen.</p>

<p>La IA escribe notas, verifica contra la documentación oficial y responde a mis preguntas. Pero las reglas del vault las decidí yo, y la última revisión antes de usar cualquier dato con un cliente sigue siendo humana.</p>

<hr />

<p><em>Si tienes preguntas sobre la configuración o quieres los scripts, puedes contactarme en <a href="mailto:luisadanmunoz@outlook.com">luisadanmunoz@outlook.com</a>.</em></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Productividad" /><category term="IA" /><category term="obsidian" /><category term="claude-code" /><category term="git" /><category term="gitleaks" /><category term="templater" /><category term="azure" /><category term="knowledge-management" /><summary type="html"><![CDATA[Como consultor Azure, mi conocimiento estaba repartido por todas partes: ofertas en carpetas locales, chuletas de comandos en ficheros sueltos, apuntes de certificaciones en PDFs, módulos de Terraform en repos, y decenas de “esto ya lo resolví una vez, pero ¿dónde?”.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Terraform Agent, paso 2: SchemaManager como fuente de verdad</title><link href="https://luisadanmunoz.es/posts/blob_post_3_schemamanager/" rel="alternate" type="text/html" title="Terraform Agent, paso 2: SchemaManager como fuente de verdad" /><published>2026-03-24T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/blob_post_3_schemamanager</id><content type="html" xml:base="https://luisadanmunoz.es/posts/blob_post_3_schemamanager/"><![CDATA[<h1 id="paso-2-el-módulo-que-impide-que-un-llm-invente-atributos-de-terraform">Paso 2: El módulo que impide que un LLM invente atributos de Terraform</h1>

<p><em>~15 MB de JSON. Un índice en memoria. Cero alucinaciones de atributos. Así funciona el SchemaManager.</em></p>

<hr />

<p>Si llegaste desde el post anterior, ya sabes que el Paso 1 definió los contratos de datos entre agentes. Este post cubre el Paso 2: el componente que convierte el schema real del provider azurerm en la única fuente de verdad que el Builder puede consultar.</p>

<p>El Paso 2 tampoco llama a ningún LLM. Tampoco toca Azure. Pero sin él, todo lo que venga después es frágil.</p>

<hr />

<h2 id="el-problema-concreto">El problema concreto</h2>

<p>Los LLMs cometen un tipo de error muy específico cuando generan Terraform: inventan atributos que no existen o usan nombres de versiones anteriores del provider.</p>

<p>Algunos ejemplos reales con azurerm 4.x:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ❌ enable_https_traffic_only fue renombrado en azurerm 3.x</span>
<span class="nx">resource</span> <span class="s2">"azurerm_storage_account"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">enable_https_traffic_only</span> <span class="o">=</span> <span class="kc">true</span>  <span class="c1"># no existe en 4.x</span>
<span class="p">}</span>

<span class="c1"># ❌ address_prefix no existe en azurerm_virtual_network</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">address_prefix</span> <span class="o">=</span> <span class="s2">"10.0.0.0/16"</span>  <span class="c1"># el atributo es address_space (lista)</span>
<span class="p">}</span>

<span class="c1"># ❌ subnet inline está deprecated</span>
<span class="nx">resource</span> <span class="s2">"azurerm_virtual_network"</span> <span class="s2">"main"</span> <span class="p">{</span>
  <span class="nx">subnet</span> <span class="p">{</span>
    <span class="nx">name</span>           <span class="o">=</span> <span class="s2">"snet-app"</span>
    <span class="nx">address_prefix</span> <span class="o">=</span> <span class="s2">"10.0.1.0/24"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Ninguno de estos errores se detecta en el prompt. Se detectan en <code class="language-plaintext highlighter-rouge">terraform validate</code>, que es la cuarta etapa del pipeline de revisión. Si el Builder llega hasta ahí antes de enterarse, ha desperdiciado tres etapas y una iteración completa del bucle de reintentos.</p>

<p>El SchemaManager mueve esa detección al origen: antes de generar una sola línea de HCL.</p>

<hr />

<h2 id="qué-se-construyó">Qué se construyó</h2>

<p>Un módulo Python con tres modelos internos y una interfaz pública de ocho métodos:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>schema/
├── schema_manager.py        # módulo principal
└── azurerm_4_0.json         # generado con terraform providers schema -json (~15 MB)

tests/
└── schema/
    ├── test_schema_manager.py
    └── fixtures/
        └── azurerm_minimal.json  # 3 recursos para CI, sin depender del JSON real
</code></pre></div></div>

<hr />

<h2 id="cómo-funciona-por-dentro">Cómo funciona por dentro</h2>

<p>El JSON que produce <code class="language-plaintext highlighter-rouge">terraform providers schema -json</code> tiene esta estructura para cada recurso:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"provider_schemas"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"registry.terraform.io/hashicorp/azurerm"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"resource_schemas"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"azurerm_virtual_network"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">,</span><span class="w">
          </span><span class="nl">"block"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"attributes"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
              </span><span class="nl">"address_space"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"list"</span><span class="p">,</span><span class="s2">"string"</span><span class="p">],</span><span class="w"> </span><span class="nl">"required"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="nl">"location"</span><span class="p">:</span><span class="w">      </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">,</span><span class="w"> </span><span class="nl">"required"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">},</span><span class="w">
              </span><span class="nl">"id"</span><span class="p">:</span><span class="w">            </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">,</span><span class="w"> </span><span class="nl">"computed"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">},</span><span class="w">
            </span><span class="nl">"block_types"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
              </span><span class="nl">"ddos_protection_plan"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"nesting_mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"list"</span><span class="p">,</span><span class="w"> </span><span class="nl">"block"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="err">...</span><span class="p">}</span><span class="w"> </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>El SchemaManager carga este JSON una sola vez en <code class="language-plaintext highlighter-rouge">__init__</code>, navega hasta <code class="language-plaintext highlighter-rouge">resource_schemas</code> y construye un índice en memoria. Cada recurso se convierte en un objeto <code class="language-plaintext highlighter-rouge">ResourceSchema</code> con cuatro colecciones ya clasificadas:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@dataclass</span>
<span class="k">class</span> <span class="nc">ResourceSchema</span><span class="p">:</span>
    <span class="n">resource_type</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">required_attributes</span><span class="p">:</span>      <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">AttributeSchema</span><span class="p">]</span>
    <span class="n">optional_attributes</span><span class="p">:</span>      <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">AttributeSchema</span><span class="p">]</span>
    <span class="n">computed_only_attributes</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">AttributeSchema</span><span class="p">]</span>
    <span class="n">blocks</span><span class="p">:</span>                   <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">BlockSchema</span><span class="p">]</span>

    <span class="k">def</span> <span class="nf">summary</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
        <span class="sh">"""</span><span class="s">Versión compacta para prompts del Builder.</span><span class="sh">"""</span>
        <span class="k">return</span> <span class="p">{</span>
            <span class="sh">"</span><span class="s">required_attributes</span><span class="sh">"</span><span class="p">:</span>      <span class="nf">list</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">required_attributes</span><span class="p">.</span><span class="nf">keys</span><span class="p">()),</span>
            <span class="sh">"</span><span class="s">optional_attributes</span><span class="sh">"</span><span class="p">:</span>      <span class="nf">list</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">optional_attributes</span><span class="p">.</span><span class="nf">keys</span><span class="p">()),</span>
            <span class="sh">"</span><span class="s">computed_only_attributes</span><span class="sh">"</span><span class="p">:</span> <span class="nf">list</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">computed_only_attributes</span><span class="p">.</span><span class="nf">keys</span><span class="p">()),</span>
            <span class="sh">"</span><span class="s">required_blocks</span><span class="sh">"</span><span class="p">:</span>          <span class="p">[</span><span class="n">k</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span><span class="n">v</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">blocks</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span> <span class="k">if</span> <span class="n">v</span><span class="p">.</span><span class="n">required</span><span class="p">],</span>
            <span class="sh">"</span><span class="s">optional_blocks</span><span class="sh">"</span><span class="p">:</span>          <span class="p">[</span><span class="n">k</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span><span class="n">v</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">blocks</span><span class="p">.</span><span class="nf">items</span><span class="p">()</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">v</span><span class="p">.</span><span class="n">required</span><span class="p">],</span>
        <span class="p">}</span>
</code></pre></div></div>

<p>El Builder no recibe el JSON crudo. Recibe <code class="language-plaintext highlighter-rouge">summary()</code>: una lista de nombres. Con eso tiene suficiente para saber qué puede usar, qué es obligatorio y qué no debe tocar porque es <code class="language-plaintext highlighter-rouge">computed_only</code>.</p>

<hr />

<h2 id="las-decisiones-no-obvias">Las decisiones no obvias</h2>

<h3 id="1-schemaerror-en-lugar-de-none-para-recursos-inexistentes">1. <code class="language-plaintext highlighter-rouge">SchemaError</code> en lugar de <code class="language-plaintext highlighter-rouge">None</code> para recursos inexistentes</h3>

<p>La primera versión devolvía <code class="language-plaintext highlighter-rouge">None</code> si un recurso no existía en el schema. El problema: cada caller tenía que manejar el caso nulo.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ❌ con None — el error puede propagarse silenciosamente
</span><span class="n">schema</span> <span class="o">=</span> <span class="n">sm</span><span class="p">.</span><span class="nf">get_resource_schema</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_nonexistent</span><span class="sh">"</span><span class="p">)</span>
<span class="k">if</span> <span class="n">schema</span><span class="p">:</span>
    <span class="n">attrs</span> <span class="o">=</span> <span class="n">schema</span><span class="p">.</span><span class="n">required_attributes</span>  <span class="c1"># nunca llega aquí
# pero nadie se entera del fallo hasta más tarde
</span></code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ✅ con SchemaError — el fallo es inmediato y trazable
</span><span class="k">try</span><span class="p">:</span>
    <span class="n">schema</span> <span class="o">=</span> <span class="n">sm</span><span class="p">.</span><span class="nf">get_resource_schema</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_nonexistent</span><span class="sh">"</span><span class="p">)</span>
<span class="k">except</span> <span class="n">SchemaError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
    <span class="c1"># e.resource_type = "azurerm_nonexistent"
</span>    <span class="c1"># el contexto está en la excepción
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SchemaError</code> hereda de la jerarquía de excepciones del sistema y lleva <code class="language-plaintext highlighter-rouge">resource_type</code> como atributo. El Orquestador puede capturarla, loguearla con <code class="language-plaintext highlighter-rouge">pipeline_id</code> y escalar al usuario sin perder contexto.</p>

<h3 id="2-carga-única-con-índice-en-memoria">2. Carga única con índice en memoria</h3>

<p>El JSON se parsea una sola vez en <code class="language-plaintext highlighter-rouge">__init__</code>. Las consultas posteriores son búsquedas en un <code class="language-plaintext highlighter-rouge">dict</code> Python: O(1).</p>

<p>La alternativa —leer el JSON en cada consulta— parece más “pura” pero introduce latencia variable en el hot path del Builder. Con ~847 recursos y un JSON de 15 MB, la primera carga tarda ~2-3 segundos. Cada consulta posterior tarda microsegundos.</p>

<h3 id="3-normalización-de-tipos">3. Normalización de tipos</h3>

<p>El schema de Terraform representa los tipos como listas anidadas:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"list"</span><span class="p">,</span><span class="w"> </span><span class="s2">"string"</span><span class="p">]</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"map"</span><span class="p">,</span><span class="w"> </span><span class="s2">"number"</span><span class="p">]</span><span class="w">
</span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"object"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nl">"ip_configuration"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="err">...</span><span class="p">]}]</span><span class="w">
</span></code></pre></div></div>

<p>El SchemaManager normaliza esto a strings legibles antes de exponerlo:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="sh">"</span><span class="s">list</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">string</span><span class="sh">"</span><span class="p">]</span>          <span class="err">→</span> <span class="sh">"</span><span class="s">list(string)</span><span class="sh">"</span>
<span class="p">[</span><span class="sh">"</span><span class="s">map</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">number</span><span class="sh">"</span><span class="p">]</span>           <span class="err">→</span> <span class="sh">"</span><span class="s">map(number)</span><span class="sh">"</span>
<span class="p">[</span><span class="sh">"</span><span class="s">object</span><span class="sh">"</span><span class="p">,</span> <span class="p">{</span><span class="sh">"</span><span class="s">a</span><span class="sh">"</span><span class="p">:</span> <span class="p">...,</span> <span class="sh">"</span><span class="s">b</span><span class="sh">"</span><span class="p">:</span> <span class="p">...}]</span> <span class="err">→</span> <span class="sh">"</span><span class="s">object(a, b)</span><span class="sh">"</span>
</code></pre></div></div>

<p>El Builder trabaja con <code class="language-plaintext highlighter-rouge">"list(string)"</code>, no con <code class="language-plaintext highlighter-rouge">["list", "string"]</code>. Es un detalle pequeño con impacto grande en la legibilidad de los prompts y en los tests.</p>

<h3 id="4-fixture-minimal-para-ci">4. Fixture minimal para CI</h3>

<p>Los tests no dependen del JSON real de 15 MB. Un fixture de 3 recursos cubre toda la interfaz pública:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">azurerm_resource_group</code> — recurso simple, solo atributos</li>
  <li><code class="language-plaintext highlighter-rouge">azurerm_virtual_network</code> — recurso con bloques anidados</li>
  <li><code class="language-plaintext highlighter-rouge">azurerm_storage_account</code> — recurso con atributos sensitive y computed</li>
</ul>

<p>Esto permite ejecutar la suite completa en CI en menos de un segundo, sin Terraform instalado, sin descargar el provider, sin conexión a internet.</p>

<hr />

<h2 id="la-interfaz-completa">La interfaz completa</h2>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">schema</span> <span class="kn">import</span> <span class="n">SchemaManager</span>

<span class="n">sm</span> <span class="o">=</span> <span class="nc">SchemaManager</span><span class="p">(</span><span class="sh">"</span><span class="s">schema/azurerm_4_0.json</span><span class="sh">"</span><span class="p">)</span>

<span class="c1"># Cuántos recursos hay
</span><span class="nf">print</span><span class="p">(</span><span class="n">sm</span><span class="p">.</span><span class="n">resource_count</span><span class="p">)</span>  <span class="c1"># 847
</span>
<span class="c1"># Existe un recurso?
</span><span class="n">sm</span><span class="p">.</span><span class="nf">resource_exists</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_virtual_network</span><span class="sh">"</span><span class="p">)</span>  <span class="c1"># True
</span><span class="n">sm</span><span class="p">.</span><span class="nf">resource_exists</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_nonexistent</span><span class="sh">"</span><span class="p">)</span>       <span class="c1"># False
</span>
<span class="c1"># Schema completo
</span><span class="n">schema</span> <span class="o">=</span> <span class="n">sm</span><span class="p">.</span><span class="nf">get_resource_schema</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_virtual_network</span><span class="sh">"</span><span class="p">)</span>
<span class="n">schema</span><span class="p">.</span><span class="n">required_attributes</span>   <span class="c1"># {"address_space": AttributeSchema(...), ...}
</span><span class="n">schema</span><span class="p">.</span><span class="n">optional_attributes</span>   <span class="c1"># {"bgp_community": ..., "dns_servers": ..., ...}
</span><span class="n">schema</span><span class="p">.</span><span class="n">computed_only_attributes</span>  <span class="c1"># {"id": ..., "guid": ...}
</span><span class="n">schema</span><span class="p">.</span><span class="n">blocks</span>                <span class="c1"># {"ddos_protection_plan": BlockSchema(...), ...}
</span>
<span class="c1"># Resumen para prompts
</span><span class="n">sm</span><span class="p">.</span><span class="nf">get_summary</span><span class="p">(</span><span class="sh">"</span><span class="s">azurerm_virtual_network</span><span class="sh">"</span><span class="p">)</span>
<span class="c1"># {
#   "required_attributes": ["address_space", "location", "name", "resource_group_name"],
#   "optional_attributes": ["bgp_community", "dns_servers", "tags", ...],
#   "computed_only_attributes": ["guid", "id"],
#   "required_blocks": [],
#   "optional_blocks": ["ddos_protection_plan", "encryption", "timeouts"]
# }
</span></code></pre></div></div>

<p>Con <code class="language-plaintext highlighter-rouge">summary()</code>, el Builder puede construir un prompt del tipo:</p>

<blockquote>
  <p><em>“Genera HCL para azurerm_virtual_network. Atributos requeridos: address_space, location, name, resource_group_name. No incluyas atributos computed_only: guid, id.”</em></p>
</blockquote>

<p>El LLM no adivina. Trabaja con una lista exacta extraída del schema real del provider.</p>

<hr />

<h2 id="cómo-replicarlo">Cómo replicarlo</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Generar el JSON del provider (solo la primera vez)</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> schema
<span class="nb">cd</span> /tmp <span class="o">&amp;&amp;</span> <span class="nb">cat</span> <span class="o">&gt;</span> provider.tf <span class="o">&lt;&lt;</span> <span class="sh">'</span><span class="no">EOF</span><span class="sh">'
terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~&gt; 4.0" }
  }
}
provider "azurerm" { features {} }
</span><span class="no">EOF
</span>terraform init
terraform providers schema <span class="nt">-json</span> <span class="o">&gt;</span> /ruta/proyecto/schema/azurerm_4_0.json

<span class="c"># 2. Añadir a .gitignore (no commitear los 15 MB)</span>
<span class="nb">echo</span> <span class="s2">"schema/azurerm_4_0.json"</span> <span class="o">&gt;&gt;</span> .gitignore

<span class="c"># 3. Ejecutar los tests (con fixture minimal, sin el JSON real)</span>
pytest tests/schema/ <span class="nt">-v</span> <span class="nt">--cov</span><span class="o">=</span>schema <span class="nt">--cov-report</span><span class="o">=</span>term-missing

<span class="c"># 4. Verificación manual con el JSON real</span>
python <span class="nt">-c</span> <span class="s2">"
from schema import SchemaManager
sm = SchemaManager('schema/azurerm_4_0.json')
print(f'Recursos indexados: {sm.resource_count}')
print(sm.get_summary('azurerm_virtual_network'))
"</span>
</code></pre></div></div>

<p>Resultado esperado:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Recursos indexados: 847
{
  'required_attributes': ['address_space', 'location', 'name', 'resource_group_name'],
  'optional_attributes': ['bgp_community', 'dns_servers', 'edge_zone', 'tags', ...],
  'optional_blocks': ['ddos_protection_plan', 'encryption', 'timeouts']
}
</code></pre></div></div>

<hr />

<h2 id="lo-que-este-paso-no-hace">Lo que este paso no hace</h2>

<p>No valida que los <em>valores</em> de los atributos sean correctos. Sabe que <code class="language-plaintext highlighter-rouge">address_space</code> existe y es de tipo <code class="language-plaintext highlighter-rouge">list(string)</code>, pero no sabe si <code class="language-plaintext highlighter-rouge">["10.0.0.0/8", "192.168.0.0/16"]</code> es una combinación válida para ese entorno.</p>

<p>Esa validación semántica ocurre en el Revisor, en la etapa <code class="language-plaintext highlighter-rouge">checkov</code> y <code class="language-plaintext highlighter-rouge">terraform plan</code>. El SchemaManager resuelve un problema más acotado: que el Builder no use atributos que no existen. Lo demás tiene su sitio más adelante en el pipeline.</p>

<hr />

<h2 id="siguiente-paso">Siguiente paso</h2>

<p>El Paso 3 configura el backend remoto de tfstate en Azure Blob Storage y la autenticación OIDC desde GitHub Actions.</p>

<p>Con los contratos y el SchemaManager en su sitio, el siguiente bloque es la infraestructura de soporte que hace posible ejecutar <code class="language-plaintext highlighter-rouge">terraform plan</code> desde el pipeline sin secretos de larga duración. Sin esto, el Revisor no puede ejecutar el plan porque no tiene acceso al state ni credenciales seguras para Azure.</p>

<p>Es el primer paso que requiere recursos reales en Azure.</p>

<hr />

<p><em>El código de este paso, la documentación técnica completa y los tests están disponibles en el repositorio del proyecto.</em></p>

<hr />

<p><strong>Tags:</strong> <code class="language-plaintext highlighter-rouge">terraform</code> <code class="language-plaintext highlighter-rouge">azure</code> <code class="language-plaintext highlighter-rouge">python</code> <code class="language-plaintext highlighter-rouge">multiagent</code> <code class="language-plaintext highlighter-rouge">llm</code> <code class="language-plaintext highlighter-rouge">iac</code> <code class="language-plaintext highlighter-rouge">platform-engineering</code> <code class="language-plaintext highlighter-rouge">software-architecture</code></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="IA" /><category term="IaC" /><category term="terraform" /><category term="azure" /><category term="python" /><category term="multiagent" /><category term="llm" /><category term="iac" /><category term="platform-engineering" /><category term="software-architecture" /><summary type="html"><![CDATA[Paso 2: El módulo que impide que un LLM invente atributos de Terraform]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Terraform Agent, paso 1: contratos Pydantic antes que agentes</title><link href="https://luisadanmunoz.es/posts/blog_post_2_step1_tecnico/" rel="alternate" type="text/html" title="Terraform Agent, paso 1: contratos Pydantic antes que agentes" /><published>2026-03-24T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/blog_post_2_step1_tecnico</id><content type="html" xml:base="https://luisadanmunoz.es/posts/blog_post_2_step1_tecnico/"><![CDATA[<h1 id="paso-1-por-qué-empecé-un-sistema-multiagente-definiendo-contratos-pydantic-antes-de-escribir-ningún-agente">Paso 1: Por qué empecé un sistema multiagente definiendo contratos Pydantic antes de escribir ningún agente</h1>

<p><em>Antes de un solo LLM, antes de Terraform, antes de Azure: modelos de datos. Aquí está el razonamiento y las decisiones no obvias.</em></p>

<hr />

<p>Si llegaste desde el post anterior, ya sabes qué estoy construyendo. Si no, la versión corta: un pipeline multiagente que convierte lenguaje natural en infraestructura Azure desplegada con Terraform. Este post cubre el Paso 1 en detalle.</p>

<p>El Paso 1 no llama a ningún LLM. No genera HCL. No toca Azure. Tampoco es aburrido.</p>

<hr />

<h2 id="el-problema-que-resuelve-este-paso">El problema que resuelve este paso</h2>

<p>Un sistema con cinco agentes tiene cinco fronteras de datos. Cada frontera es un sitio donde el sistema puede fallar silenciosamente si no tienes contratos explícitos.</p>

<p>Sin contratos formales, un agente que produce un diccionario con clave <code class="language-plaintext highlighter-rouge">resource_typ</code> (sin ‘e’) no falla al crearse. Falla cuando el siguiente agente intenta leer <code class="language-plaintext highlighter-rouge">resource_type</code> y obtiene <code class="language-plaintext highlighter-rouge">None</code>. Si ese fallo ocurre dentro de una llamada LLM que está generando HCL, el error que ves es un problema de Terraform, no un problema de tipado Python. La pila de traza no te lleva al sitio real.</p>

<p>Pydantic v2 resuelve esto en el borde: si un modelo no valida, lanza <code class="language-plaintext highlighter-rouge">ValidationError</code> antes de que el dato contaminado cruce la frontera. El fallo es explícito, localizado y tiene contexto.</p>

<hr />

<h2 id="qué-se-construyó">Qué se construyó</h2>

<p>Seis modelos de contrato, un módulo de utilidades, configuración del paquete y tests:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>contracts/
├── __init__.py              # exports centralizados
├── requirements.py          # InfraRequirements
├── recommendations.py       # ArchitectRecommendations
├── hcl_output.py            # HCLOutput
├── review_result.py         # ReviewResult
├── pipeline_state.py        # PipelineState
└── conversation.py          # ConversationOutput

core/
├── exceptions.py            # excepciones tipadas
└── logging.py               # logging estructurado

tests/
├── conftest.py              # fixtures reutilizables
└── contracts/
    └── test_contracts.py    # suite completa
</code></pre></div></div>

<p>Cada modelo define exactamente qué agente lo produce, qué agente lo consume, y qué invariantes debe cumplir.</p>

<hr />

<h2 id="las-decisiones-no-obvias">Las decisiones no obvias</h2>

<h3 id="1-pipeline_id-se-genera-una-sola-vez-y-solo-se-propaga">1. <code class="language-plaintext highlighter-rouge">pipeline_id</code> se genera una sola vez y solo se propaga</h3>

<p>El Orquestador crea un <code class="language-plaintext highlighter-rouge">uuid.UUID</code> antes de invocar al primer agente. Desde ahí, ese UUID viaja en todos los contratos sin regenerarse.</p>

<p>Ningún agente crea su propio <code class="language-plaintext highlighter-rouge">pipeline_id</code>. Solo lo recibe y lo reenvía.</p>

<p>La consecuencia práctica: puedes hacer <code class="language-plaintext highlighter-rouge">grep</code> de un UUID en los logs y ver la traza completa de una ejecución a través de cinco agentes. Sin esto, correlacionar un fallo del Revisor en la iteración 3 con los requisitos originales del Conversacional requiere heurísticas.</p>

<p>El tipo elegido es <code class="language-plaintext highlighter-rouge">uuid.UUID</code>, no <code class="language-plaintext highlighter-rouge">str</code>. En serialización JSON con <code class="language-plaintext highlighter-rouge">model_dump(mode="json")</code> se convierte automáticamente a string. Al deserializar, <code class="language-plaintext highlighter-rouge">model_validate</code> reconstruye el <code class="language-plaintext highlighter-rouge">UUID</code>. El coste operativo es mínimo; la precisión semántica vale la pena.</p>

<h3 id="2-reviewresultpassed-es-una-property-no-un-campo">2. <code class="language-plaintext highlighter-rouge">ReviewResult.passed</code> es una <code class="language-plaintext highlighter-rouge">@property</code>, no un campo</h3>

<p>El Revisor ejecuta cinco etapas: <code class="language-plaintext highlighter-rouge">fmt</code>, <code class="language-plaintext highlighter-rouge">tflint</code>, <code class="language-plaintext highlighter-rouge">checkov</code>, <code class="language-plaintext highlighter-rouge">validate</code>, <code class="language-plaintext highlighter-rouge">plan</code>. Cada una produce un <code class="language-plaintext highlighter-rouge">ReviewCheck</code> con su estado. El resultado global <code class="language-plaintext highlighter-rouge">passed</code> es <code class="language-plaintext highlighter-rouge">True</code> si y solo si todas las etapas pasaron.</p>

<p>Si <code class="language-plaintext highlighter-rouge">passed</code> fuera un campo editable, podría desincronizarse con la lista de checks. Un agente podría construir un <code class="language-plaintext highlighter-rouge">ReviewResult</code> con tres checks fallidos y <code class="language-plaintext highlighter-rouge">passed=True</code>, y el Orquestador lo trataría como éxito.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@property</span>
<span class="k">def</span> <span class="nf">passed</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
    <span class="k">return</span> <span class="nf">all</span><span class="p">(</span><span class="n">c</span><span class="p">.</span><span class="n">status</span> <span class="o">==</span> <span class="n">ReviewCheckStatus</span><span class="p">.</span><span class="n">PASSED</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">checks</span><span class="p">)</span>
</code></pre></div></div>

<p>El problema secundario: Pydantic no incluye <code class="language-plaintext highlighter-rouge">@property</code> en <code class="language-plaintext highlighter-rouge">model_dump()</code>. Para persistencia, se usa <code class="language-plaintext highlighter-rouge">to_snapshot()</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">to_snapshot</span><span class="p">(</span><span class="n">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">model_dump</span><span class="p">(</span><span class="n">mode</span><span class="o">=</span><span class="sh">"</span><span class="s">json</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">passed</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">passed</span>  <span class="c1"># inclusión explícita
</span>    <span class="k">return</span> <span class="n">data</span>
</code></pre></div></div>

<h3 id="3-subnetspec-como-modelo-no-como-dict">3. <code class="language-plaintext highlighter-rouge">SubnetSpec</code> como modelo, no como <code class="language-plaintext highlighter-rouge">dict</code></h3>

<p>La primera versión usaba <code class="language-plaintext highlighter-rouge">list[dict[str, str]]</code> para las subredes. Funcionaba. Pero Pydantic no puede validar el contenido de un diccionario genérico: si <code class="language-plaintext highlighter-rouge">address_prefix</code> tiene un valor malformado, no te enteras hasta que el Builder genera HCL inválido.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">SubnetSpec</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">name</span><span class="p">:</span> <span class="nb">str</span>
    <span class="n">address_prefix</span><span class="p">:</span> <span class="nb">str</span>  <span class="c1"># próximo paso: @field_validator con ip_network()
</span></code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># En NetworkSpec:
</span><span class="n">subnets</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">SubnetSpec</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</code></pre></div></div>

<p>La interfaz es idéntica desde fuera. La capacidad de añadir validación de CIDR en el futuro sin cambiar la interfaz es lo que lo justifica.</p>

<h3 id="4-config-dictstr-object-no-dict-a-secas">4. <code class="language-plaintext highlighter-rouge">config: dict[str, object]</code>, no <code class="language-plaintext highlighter-rouge">dict</code> a secas</h3>

<p><code class="language-plaintext highlighter-rouge">ResourceSpec</code> tiene un campo <code class="language-plaintext highlighter-rouge">config</code> para atributos específicos de cada recurso Terraform. No puedes modelar estos atributos con un esquema fijo sin duplicar el schema del provider.</p>

<p><code class="language-plaintext highlighter-rouge">dict</code> en Pydantic v2 equivale a <code class="language-plaintext highlighter-rouge">dict[Any, Any]</code>. <code class="language-plaintext highlighter-rouge">dict[str, object]</code> fuerza que las claves sean strings (que es lo que Terraform espera) y documenta la intención. Los valores siguen siendo libres, pero eso es correcto: el Builder los validará contra el schema del provider en el Paso 2.</p>

<h3 id="5-agentstepstatus-como-enum-no-como-str">5. <code class="language-plaintext highlighter-rouge">AgentStepStatus</code> como <code class="language-plaintext highlighter-rouge">Enum</code>, no como <code class="language-plaintext highlighter-rouge">str</code></h3>

<p>Los estados posibles de un paso del agente son un conjunto cerrado: <code class="language-plaintext highlighter-rouge">PENDING</code>, <code class="language-plaintext highlighter-rouge">RUNNING</code>, <code class="language-plaintext highlighter-rouge">SUCCESS</code>, <code class="language-plaintext highlighter-rouge">FAILED</code>, <code class="language-plaintext highlighter-rouge">SKIPPED</code>. Un <code class="language-plaintext highlighter-rouge">str</code> libre no falla al construirse con el valor <code class="language-plaintext highlighter-rouge">"succes"</code>. Un <code class="language-plaintext highlighter-rouge">Enum</code> sí.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">AgentStepStatus</span><span class="p">(</span><span class="nb">str</span><span class="p">,</span> <span class="n">Enum</span><span class="p">):</span>
    <span class="n">PENDING</span> <span class="o">=</span> <span class="sh">"</span><span class="s">pending</span><span class="sh">"</span>
    <span class="n">RUNNING</span> <span class="o">=</span> <span class="sh">"</span><span class="s">running</span><span class="sh">"</span>
    <span class="n">SUCCESS</span> <span class="o">=</span> <span class="sh">"</span><span class="s">success</span><span class="sh">"</span>
    <span class="n">FAILED</span> <span class="o">=</span> <span class="sh">"</span><span class="s">failed</span><span class="sh">"</span>
    <span class="n">SKIPPED</span> <span class="o">=</span> <span class="sh">"</span><span class="s">skipped</span><span class="sh">"</span>
</code></pre></div></div>

<p>Hereda de <code class="language-plaintext highlighter-rouge">str</code> para que <code class="language-plaintext highlighter-rouge">model_dump(mode="json")</code> serialice el valor como string, no como objeto Enum.</p>

<h3 id="6-logging-estructurado-desde-el-principio">6. Logging estructurado desde el principio</h3>

<p>El logging estándar de Python no acepta kwargs arbitrarios. Este código lanza <code class="language-plaintext highlighter-rouge">TypeError</code> en runtime:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ❌ no funciona con logging estándar
</span><span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Builder iteration</span><span class="sh">"</span><span class="p">,</span> <span class="n">pipeline_id</span><span class="o">=</span><span class="sh">"</span><span class="s">abc</span><span class="sh">"</span><span class="p">,</span> <span class="n">iteration</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
</code></pre></div></div>

<p>La forma correcta, compatible con cualquier handler JSON (Loki, Datadog, etc.):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ✅
</span><span class="n">logger</span><span class="p">.</span><span class="nf">info</span><span class="p">(</span><span class="sh">"</span><span class="s">Builder iteration</span><span class="sh">"</span><span class="p">,</span> <span class="n">extra</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">pipeline_id</span><span class="sh">"</span><span class="p">:</span> <span class="nf">str</span><span class="p">(</span><span class="n">pipeline_id</span><span class="p">),</span> <span class="sh">"</span><span class="s">iteration</span><span class="sh">"</span><span class="p">:</span> <span class="mi">2</span><span class="p">})</span>
</code></pre></div></div>

<p>Es un detalle pequeño que, si no lo corriges desde el principio, se reproduce en cada agente y se convierte en una deuda técnica difusa.</p>

<hr />

<h2 id="diagrama-1--modelos-de-datos">Diagrama 1 — modelos de datos:</h2>

<pre><code class="language-mermaid">graph TB
    CO[ConversationOutput] --&gt;|contiene| IR[InfraRequirements]
    IR --&gt;|contiene| RS[ResourceSpec]
    IR --&gt;|contiene| TS[TagsSpec]
    RS -.-&gt;|opcional| NS[NetworkSpec]
    NS --&gt;|lista| SS[SubnetSpec]
    AR[ArchitectRecommendations] -.-&gt;|referencia| IR
    HCL[HCLOutput] -.-&gt;|referencia| IR
    RR[ReviewResult] --&gt;|lista| RC[ReviewCheck]
    PS[PipelineState] --&gt;|lista| AS[AgentStep]
    PS -.-&gt;|snapshot| IR
    PS -.-&gt;|snapshot| HCL
    PS -.-&gt;|snapshot| RR

    style CO fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style IR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style AR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style HCL fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style RR fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style PS fill:#2a1a3a,stroke:#8b5cf6,color:#fff
    style AS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style RC fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style RS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style NS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style SS fill:#1a2a3a,stroke:#3b82f6,color:#fff
    style TS fill:#1a2a3a,stroke:#3b82f6,color:#fff
</code></pre>
<h2 id="diagrama-2--flujo-de-agentes">Diagrama 2 — flujo de agentes:</h2>
<pre><code class="language-mermaid">graph LR
    ORC([Orquestador]) --&gt;|genera pipeline_id| CONV[Conversacional]
    CONV --&gt;|produce ConversationOutput| ARQ[Arquitecto]
    CONV --&gt;|produce ConversationOutput| BUILDER[Builder]
    ARQ --&gt;|produce ArchitectRecommendations| BUILDER
    BUILDER --&gt;|produce HCLOutput| REVISOR[Revisor]
    REVISOR --&gt;|produce ReviewResult| BUILDER
    REVISOR --&gt;|passed=true| ORC
    ORC --&gt;|mantiene PipelineState| ORC

    style ORC fill:#1F4E79,stroke:#2E75B6,color:#fff
    style CONV fill:#1e3a5f,stroke:#6366f1,color:#fff
    style ARQ fill:#1e3a5f,stroke:#6366f1,color:#fff
    style BUILDER fill:#1a3a2a,stroke:#10b981,color:#fff
    style REVISOR fill:#3a2a1a,stroke:#f59e0b,color:#fff
</code></pre>

<hr />

<h2 id="cómo-replicarlo">Cómo replicarlo</h2>

<p>Prerequisitos: Python 3.11+, pip.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Crear entorno virtual</span>
python <span class="nt">-m</span> venv .venv

<span class="c"># Linux/macOS</span>
<span class="nb">source</span> .venv/bin/activate

<span class="c"># Windows PowerShell</span>
.venv<span class="se">\S</span>cripts<span class="se">\A</span>ctivate.ps1

<span class="c"># 2. Instalar en modo editable con dependencias de dev</span>
pip <span class="nb">install</span> <span class="nt">-e</span> <span class="s2">".[dev]"</span>

<span class="c"># 3. Ejecutar los tests</span>
pytest tests/ <span class="nt">-v</span> <span class="nt">--cov</span><span class="o">=</span>contracts <span class="nt">--cov</span><span class="o">=</span>core <span class="nt">--cov-report</span><span class="o">=</span>term-missing
</code></pre></div></div>

<p>Resultado esperado: 30 tests en verde, cobertura &gt;90% en la capa de contratos.</p>

<p>Verificación manual rápida para confirmar los invariantes críticos:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="n">uuid</span> <span class="kn">import</span> <span class="n">uuid4</span>
<span class="kn">from</span> <span class="n">contracts</span> <span class="kn">import</span> <span class="n">InfraRequirements</span><span class="p">,</span> <span class="n">ResourceSpec</span><span class="p">,</span> <span class="n">TagsSpec</span>
<span class="kn">from</span> <span class="n">contracts</span> <span class="kn">import</span> <span class="n">ReviewResult</span><span class="p">,</span> <span class="n">ReviewCheck</span><span class="p">,</span> <span class="n">ReviewStage</span><span class="p">,</span> <span class="n">ReviewCheckStatus</span>
<span class="kn">import</span> <span class="n">json</span>

<span class="n">pid</span> <span class="o">=</span> <span class="nf">uuid4</span><span class="p">()</span>

<span class="c1"># UUID serializa a str en JSON
</span><span class="n">req</span> <span class="o">=</span> <span class="nc">InfraRequirements</span><span class="p">(</span>
    <span class="n">pipeline_id</span><span class="o">=</span><span class="n">pid</span><span class="p">,</span> <span class="n">client</span><span class="o">=</span><span class="sh">"</span><span class="s">acme</span><span class="sh">"</span><span class="p">,</span> <span class="n">environment</span><span class="o">=</span><span class="sh">"</span><span class="s">dev</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">location</span><span class="o">=</span><span class="sh">"</span><span class="s">westeurope</span><span class="sh">"</span><span class="p">,</span>
    <span class="n">tags</span><span class="o">=</span><span class="nc">TagsSpec</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="sh">"</span><span class="s">dev</span><span class="sh">"</span><span class="p">,</span> <span class="n">owner</span><span class="o">=</span><span class="sh">"</span><span class="s">alice@example.com</span><span class="sh">"</span><span class="p">),</span>
    <span class="n">resources</span><span class="o">=</span><span class="p">[</span><span class="nc">ResourceSpec</span><span class="p">(</span><span class="n">resource_type</span><span class="o">=</span><span class="sh">"</span><span class="s">azurerm_virtual_network</span><span class="sh">"</span><span class="p">,</span> <span class="n">logical_name</span><span class="o">=</span><span class="sh">"</span><span class="s">vnet</span><span class="sh">"</span><span class="p">)]</span>
<span class="p">)</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">req</span><span class="p">.</span><span class="nf">model_dump</span><span class="p">(</span><span class="n">mode</span><span class="o">=</span><span class="sh">"</span><span class="s">json</span><span class="sh">"</span><span class="p">)</span>
<span class="k">assert</span> <span class="nf">isinstance</span><span class="p">(</span><span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">pipeline_id</span><span class="sh">"</span><span class="p">],</span> <span class="nb">str</span><span class="p">)</span>
<span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">data</span><span class="p">)</span>  <span class="c1"># no lanza
</span>
<span class="c1"># passed es propiedad calculada, no campo editable
</span><span class="n">result</span> <span class="o">=</span> <span class="nc">ReviewResult</span><span class="p">(</span><span class="n">pipeline_id</span><span class="o">=</span><span class="n">pid</span><span class="p">,</span> <span class="n">iteration</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">checks</span><span class="o">=</span><span class="p">[</span>
    <span class="nc">ReviewCheck</span><span class="p">(</span><span class="n">stage</span><span class="o">=</span><span class="n">ReviewStage</span><span class="p">.</span><span class="n">FMT</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="n">ReviewCheckStatus</span><span class="p">.</span><span class="n">PASSED</span><span class="p">),</span>
    <span class="nc">ReviewCheck</span><span class="p">(</span><span class="n">stage</span><span class="o">=</span><span class="n">ReviewStage</span><span class="p">.</span><span class="n">TFLINT</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="n">ReviewCheckStatus</span><span class="p">.</span><span class="n">FAILED</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">E001</span><span class="sh">"</span><span class="p">]),</span>
<span class="p">])</span>
<span class="k">assert</span> <span class="n">result</span><span class="p">.</span><span class="n">passed</span> <span class="ow">is</span> <span class="bp">False</span>
<span class="k">assert</span> <span class="sh">"</span><span class="s">passed</span><span class="sh">"</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">ReviewResult</span><span class="p">.</span><span class="n">model_fields</span>  <span class="c1"># no es campo
</span><span class="n">snap</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="nf">to_snapshot</span><span class="p">()</span>
<span class="k">assert</span> <span class="n">snap</span><span class="p">[</span><span class="sh">"</span><span class="s">passed</span><span class="sh">"</span><span class="p">]</span> <span class="ow">is</span> <span class="bp">False</span>  <span class="c1"># sí aparece en snapshot
</span></code></pre></div></div>

<hr />

<h2 id="lo-que-este-paso-no-hace-y-no-debe-hacer">Lo que este paso no hace (y no debe hacer)</h2>

<p>No valida que <code class="language-plaintext highlighter-rouge">address_prefix</code> sea un CIDR real. No comprueba que <code class="language-plaintext highlighter-rouge">location</code> sea una región Azure válida. No verifica que los atributos en <code class="language-plaintext highlighter-rouge">config</code> existan en el schema del provider.</p>

<p>Esas validaciones tienen su sitio en pasos posteriores: el Arquitecto valida convenciones, el Builder valida contra el schema del provider, el Revisor valida con herramientas reales. Los contratos del Paso 1 validan estructura y tipos, no semántica de dominio.</p>

<p>Esta separación es deliberada. Si los contratos hicieran validación de dominio, cambiar la versión del provider o añadir una nueva región Azure requeriría modificar los contratos base. Con la separación actual, los contratos son estables; la lógica de dominio vive donde le corresponde.</p>

<hr />

<h2 id="siguiente-paso">Siguiente paso</h2>

<p>El Paso 2 construye el <code class="language-plaintext highlighter-rouge">SchemaManager</code>: el módulo que carga el JSON generado por <code class="language-plaintext highlighter-rouge">terraform providers schema -json</code> (~15MB para azurerm 4.x) y expone una interfaz limpia para que el Builder consulte exactamente qué atributos existen en cada recurso, cuáles son requeridos y cuáles son opcionales.</p>

<p>Sin el SchemaManager, el Builder no tiene más referencia que su entrenamiento para saber si <code class="language-plaintext highlighter-rouge">enable_https_traffic_only</code> existe en <code class="language-plaintext highlighter-rouge">azurerm_storage_account</code> versión 4.x o si fue renombrado. Con él, las alucinaciones de atributos se convierten en un error detectable antes de ejecutar un solo comando de Terraform.</p>

<hr />

<p><em>El código de este paso, la documentación técnica completa y los tests estarán disponibles en el repositorio del proyecto.</em></p>

<hr />

<p><strong>Tags:</strong> <code class="language-plaintext highlighter-rouge">terraform</code> <code class="language-plaintext highlighter-rouge">azure</code> <code class="language-plaintext highlighter-rouge">pydantic</code> <code class="language-plaintext highlighter-rouge">python</code> <code class="language-plaintext highlighter-rouge">multiagent</code> <code class="language-plaintext highlighter-rouge">llm</code> <code class="language-plaintext highlighter-rouge">iac</code> <code class="language-plaintext highlighter-rouge">platform-engineering</code> <code class="language-plaintext highlighter-rouge">software-architecture</code></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="IA" /><category term="IaC" /><category term="terraform" /><category term="azure" /><category term="pydantic" /><category term="python" /><category term="multiagent" /><category term="llm" /><category term="software-architecture" /><category term="platform-engineering" /><summary type="html"><![CDATA[Paso 1: Por qué empecé un sistema multiagente definiendo contratos Pydantic antes de escribir ningún agente]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Terraform Agent: la idea y el roadmap</title><link href="https://luisadanmunoz.es/posts/blog_post_1_idea_roadmap/" rel="alternate" type="text/html" title="Terraform Agent: la idea y el roadmap" /><published>2026-03-02T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/blog_post_1_idea_roadmap</id><content type="html" xml:base="https://luisadanmunoz.es/posts/blog_post_1_idea_roadmap/"><![CDATA[<h1 id="estoy-construyendo-un-sistema-que-convierte-lenguaje-natural-en-infraestructura-azure-desplegada-esto-es-lo-que-he-aprendido-hasta-ahora">Estoy construyendo un sistema que convierte lenguaje natural en infraestructura Azure desplegada. Esto es lo que he aprendido hasta ahora.</h1>

<p><em>Infraestructura como código generada, validada y desplegada por agentes de IA. Sin templates. Sin alucinaciones. Con Terraform real.</em></p>

<hr />

<p>Llevo un tiempo pensando en el mismo problema que muchos equipos de plataforma tienen pero pocos verbalizan bien: <strong>Terraform es potente, pero la barrera de entrada sigue siendo alta</strong>. Un desarrollador que necesita un entorno en Azure no debería tener que aprender HCL, entender el modelo de recursos de azurerm o saber qué atributos son opcionales en la versión 4.x del provider.</p>

<p>Pero la solución obvia —generar el Terraform con un LLM— tiene un problema estructural que no se resuelve simplemente con un buen prompt: <strong>los modelos de lenguaje alucinen atributos que no existen, usan versiones deprecadas del provider y producen HCL que no pasa ni el <code class="language-plaintext highlighter-rouge">terraform validate</code></strong>.</p>

<p>Así que decidí construir algo distinto. Y en esta serie de posts voy a documentar cómo.</p>

<hr />

<h2 id="la-idea-en-una-frase">La idea en una frase</h2>

<p>Un pipeline multiagente que toma requisitos en lenguaje natural, los convierte en HCL válido para Azure, lo valida con herramientas reales y lo despliega con aprobación humana en el bucle.</p>

<p>No es un chatbot que genera código. Es un sistema de producción con contratos, reintentos, estado persistente y seguridad desde el principio.</p>

<hr />

<h2 id="por-qué-los-enfoques-existentes-no-me-convencían">Por qué los enfoques existentes no me convencían</h2>

<p>He evaluado varias aproximaciones antes de construir esto:</p>

<p><strong>Módulos Terraform con variables</strong>: siguen requiriendo que alguien sepa Terraform. La barrera no desaparece, solo baja un escalón.</p>

<p><strong>Terraform CDK</strong>: añade una capa de abstracción útil, pero sigue siendo código. El desarrollador de aplicaciones que necesita un entorno a las 3pm no quiere aprender TypeScript para eso.</p>

<p><strong>LLM + prompt directo</strong>: el problema ya mencionado. Sin una fuente de verdad del schema del provider, el modelo inventa. Y cuando el HCL llega a <code class="language-plaintext highlighter-rouge">terraform plan</code>, explota.</p>

<p><strong>Herramientas comerciales de IaC con AI</strong>: existen, pero son cajas negras, costosas y no se adaptan a las restricciones de seguridad de muchas organizaciones (OIDC, tenants propios, políticas de red).</p>

<p>Lo que faltaba era un sistema que use el schema real del provider como fuente de verdad, valide el HCL generado con las mismas herramientas que usaría un ingeniero senior, y mantenga un humano en el bucle antes de aplicar cualquier cambio.</p>

<hr />

<h2 id="la-arquitectura-a-alto-nivel">La arquitectura a alto nivel</h2>

<p>Sin entrar en detalles de implementación todavía, el sistema tiene estas piezas:</p>

<p><strong>Agentes especializados</strong>, cada uno con una responsabilidad única:</p>
<ul>
  <li>Uno que recoge requisitos en lenguaje natural y los estructura</li>
  <li>Uno que consulta documentación oficial de Azure para extraer best practices</li>
  <li>Uno que genera el HCL usando el schema exacto del provider como única referencia</li>
  <li>Uno que ejecuta un pipeline de validación con herramientas reales</li>
  <li>Uno que orquesta el estado, los reintentos y las métricas</li>
</ul>

<p><strong>Infraestructura de soporte</strong> diseñada para producción desde el día uno:</p>
<ul>
  <li>Estado remoto en Azure Blob Storage con locking nativo</li>
  <li>Autenticación sin secretos de larga duración (OIDC)</li>
  <li>Flujo de aprobación humana antes de cualquier <code class="language-plaintext highlighter-rouge">terraform apply</code></li>
</ul>

<p><strong>Un bucle de corrección automática</strong> entre el generador y el validador, con límite de reintentos y escalado explícito cuando se agota.</p>

<hr />

<h2 id="el-roadmap-que-estoy-siguiendo">El roadmap que estoy siguiendo</h2>

<p>Estoy construyendo esto en fases pequeñas, donde cada paso produce algo funcional antes de avanzar al siguiente. El orden no es arbitrario: cada componente desbloquea el siguiente.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Fase 1 — Fundamentos
  ├── Paso 1: Capa de contratos (interfaces entre agentes)
  ├── Paso 2: Fuente de verdad del provider
  └── Paso 3: Backend remoto de estado + autenticación OIDC

Fase 2 — Agentes Core
  ├── Paso 4: Agente Conversacional
  ├── Paso 5: Agente Arquitecto (integración con docs oficiales)
  └── Paso 6: Agente Builder (generación de HCL)

Fase 3 — Pipeline de Revisión
  ├── Paso 7: Pipeline fmt → tflint → checkov → validate → plan
  └── Paso 8: Bucle Builder ↔ Revisor con reintentos

Fase 4 — Despliegue y Aprobación
  ├── Paso 9: Flujo de aprobación humana
  ├── Paso 10: Agente Deployer
  └── Paso 11: Orquestador con estado persistente y métricas

Fase 5 — Post-MVP
  ├── Validación de políticas con OPA
  ├── Outputs sensibles a Key Vault
  ├── Observabilidad completa
  ├── Operaciones Day 2 (modificar, destruir, importar)
  └── Detección de drift
</code></pre></div></div>

<p>Cada post de esta serie cubre un paso. Cuando termine, el repositorio completo estará disponible.</p>

<hr />

<h2 id="lo-que-he-aprendido-en-el-paso-1">Lo que he aprendido en el Paso 1</h2>

<p>El primer paso no genera infraestructura. No llama a ningún LLM. No toca Azure.</p>

<p>Define los contratos de datos entre agentes: qué información existe, qué forma tiene, y qué invariantes debe cumplir. Es la decisión de diseño más importante del sistema, y la que más se subestima.</p>

<p>Algunas decisiones no obvias que tomé aquí y que evitaron problemas más adelante:</p>

<ul>
  <li>Por qué un UUID de correlación generado una sola vez cambia completamente la depurabilidad del sistema</li>
  <li>Por qué <code class="language-plaintext highlighter-rouge">passed</code> no puede ser un campo editable si quieres que el sistema sea coherente</li>
  <li>Por qué un diccionario genérico y un modelo Pydantic no son intercambiables, aunque en Python parezca que sí</li>
</ul>

<p>El siguiente post entra en esos detalles.</p>

<hr />

<h2 id="por-qué-lo-estoy-documentando-públicamente">Por qué lo estoy documentando públicamente</h2>

<p>Dos razones.</p>

<p>La primera es práctica: documentar mientras construyes obliga a entender lo que estás haciendo. Si no puedes explicar una decisión de diseño, probablemente no la has tomado bien.</p>

<p>La segunda es que este tipo de sistema —multiagente, con herramientas reales, con seguridad desde el principio— no tiene muchos referentes documentados desde cero. La mayoría de los tutoriales de “AI + Terraform” son demos de 5 minutos que no sobrevivirían un entorno real.</p>

<p>Esto pretende ser diferente.</p>

<hr />

<p><em>Siguiente post: Paso 1 en detalle — cómo definir contratos Pydantic v2 que hacen que un sistema multiagente sea depurable, testeable y mantenible.</em></p>

<hr />

<p><strong>Tags:</strong> <code class="language-plaintext highlighter-rouge">terraform</code> <code class="language-plaintext highlighter-rouge">azure</code> <code class="language-plaintext highlighter-rouge">llm</code> <code class="language-plaintext highlighter-rouge">multiagent</code> <code class="language-plaintext highlighter-rouge">iac</code> <code class="language-plaintext highlighter-rouge">python</code> <code class="language-plaintext highlighter-rouge">pydantic</code> <code class="language-plaintext highlighter-rouge">devops</code> <code class="language-plaintext highlighter-rouge">platform-engineering</code></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="IA" /><category term="IaC" /><category term="terraform" /><category term="azure" /><category term="llm" /><category term="multiagent" /><category term="iac" /><category term="python" /><category term="platform-engineering" /><summary type="html"><![CDATA[Estoy construyendo un sistema que convierte lenguaje natural en infraestructura Azure desplegada. Esto es lo que he aprendido hasta ahora.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Azure Policy en entornos enterprise</title><link href="https://luisadanmunoz.es/posts/Azure-Policy-Enterprise/" rel="alternate" type="text/html" title="Azure Policy en entornos enterprise" /><published>2026-02-18T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/Azure-Policy-Enterprise</id><content type="html" xml:base="https://luisadanmunoz.es/posts/Azure-Policy-Enterprise/"><![CDATA[<h2 id="-1-resumen">📋 1. Resumen</h2>

<ul>
  <li>
    <p><strong>Objetivo</strong>: Implementar una estrategia enterprise de Azure Policy para garantizar cumplimiento normativo, seguridad consistente y gobernanza centralizada a través de toda la jerarquía de Management Groups, usando Policy as Code (PaC) con Enterprise Policy as Code (EPAC) como framework principal.</p>
  </li>
  <li>
    <p><strong>Dependencias</strong>:</p>
    <ul>
      <li>[[Azure-Management-Groups-Enterprise]] - Jerarquía donde se asignan las políticas</li>
      <li>[[Azure-RBAC]] - Identidades para remediation tasks y policy assignments</li>
      <li>[[Azure-Log-Analytics]] - Workspace destino para diagnostic settings via policy</li>
      <li>[[Microsoft-Sentinel]] - SIEM que consume eventos de compliance</li>
      <li>[[Azure-Automation]] - Runbooks para remediation tasks complejas</li>
      <li>[[Cost-Management]] - Presupuestos gobernados via policy</li>
    </ul>
  </li>
</ul>

<blockquote>
  <p>[!tip] Notas Importantes</p>
  <ul>
    <li>Las políticas con efecto <code class="language-plaintext highlighter-rouge">Deny</code> pueden bloquear despliegues en producción; prueba siempre en Sandbox primero</li>
    <li><code class="language-plaintext highlighter-rouge">DeployIfNotExists</code> y <code class="language-plaintext highlighter-rouge">Modify</code> requieren una Managed Identity asignada al Policy Assignment</li>
    <li>El compliance state se actualiza cada ~24h; usa <code class="language-plaintext highlighter-rouge">az policy state trigger-scan</code> para forzar evaluación</li>
    <li>Las Policy Exemptions deben tener fecha de expiración y aprobación formal</li>
    <li>EPAC es el framework recomendado por Microsoft para Policy as Code a escala enterprise</li>
    <li>Built-in policies tienen ahora versionado (Public Preview 2024): usa versiones fijas en producción</li>
    <li>Diagrama de anatomía de políticas: <code class="language-plaintext highlighter-rouge">_assets/diagrams/policy-anatomy.drawio</code></li>
    <li>Terraform modules en <code class="language-plaintext highlighter-rouge">terraform/modules/azure-policy/</code></li>
  </ul>
</blockquote>

<h2 id="️-2-arquitectura">🏛️ 2. Arquitectura</h2>

<h3 id="flujo-de-policy-as-code-con-epac">Flujo de Policy as Code con EPAC</h3>

<pre><code class="language-mermaid">flowchart TB
    subgraph Source["📁 Source Control (Git)"]
        Definitions["📜 Policy Definitions\n/Definitions"]
        Assignments["📋 Policy Assignments\n/Assignments"]
        Exemptions["⚠️ Exemptions\n/Exemptions"]
    end

    subgraph CICD["🔄 CI/CD Pipeline"]
        Validate["✅ Validate\nEPAC plan"]
        Test["🧪 Test\nPSRule + EPAC"]
        Deploy["🚀 Deploy\nEPAC deploy"]
    end

    subgraph Azure["☁️ Azure"]
        Root["🏢 Root MG\nGlobal Policies"]
        Platform["⚙️ Platform MG\nInfra Policies"]
        LZ["🎯 Landing Zones MG\nWorkload Policies"]
        Sandbox["🧪 Sandbox MG\nRelaxed Policies"]
    end

    subgraph Compliance["📊 Compliance"]
        Dashboard["📈 Policy Dashboard\nAzure Portal"]
        Remediation["🔧 Remediation Tasks"]
        Alerts["🔔 Compliance Alerts\nAzure Monitor"]
        Reports["📄 Reports\nAzDOC / AzAdvertizer"]
    end

    Source --&gt; CICD
    CICD --&gt; Azure
    Azure --&gt; Compliance

    style Source fill:#e3f2fd
    style CICD fill:#fff3e0
    style Azure fill:#e8f5e9
    style Compliance fill:#fce4ec
</code></pre>

<h3 id="anatomía-de-una-azure-policy">Anatomía de una Azure Policy</h3>

<pre><code class="language-mermaid">flowchart LR
    subgraph PolicyDef["📜 Policy Definition"]
        Meta["📋 Metadata\n- displayName\n- description\n- category\n- version"]
        Mode["⚙️ Mode\n- All\n- Indexed\n- Microsoft.xxx"]
        Params["🔧 Parameters\n- effect\n- listOfAllowedX\n- tagName"]
        Rule["📐 Policy Rule\n- if (condition)\n- then (effect)"]
    end

    subgraph Effects["⚡ Effects (por orden de evaluación)"]
        Disabled["🔕 Disabled"]
        Append["➕ Append"]
        Modify["✏️ Modify"]
        Audit["👁️ Audit"]
        AuditIfNotExists["👁️ AuditIfNotExists"]
        Deny["🚫 Deny"]
        DenyAction["🚫 DenyAction"]
        DeployIfNotExists["🔧 DeployIfNotExists"]
        Manual["📝 Manual"]
    end

    Rule --&gt; Effects
    style PolicyDef fill:#e8f5e9
    style Effects fill:#fff3e0
</code></pre>

<h3 id="jerarquía-de-assignments-y-herencia">Jerarquía de Assignments y Herencia</h3>

<pre><code class="language-mermaid">flowchart TB
    Root["🏢 Root MG\n📜 Require-Tags [Deny]\n📜 Allowed-Locations [Deny]\n📜 Audit-Diagnostics [AuditIfNotExists]\n📜 ENS-Baseline [Audit]"]

    Root --&gt;|Hereda todo| Platform["⚙️ Platform MG\n📜 Deny-Public-IPs [Deny]\n📜 Enforce-Private-Endpoints [Deny]\n📜 Require-NSG [Deny]"]

    Root --&gt;|Hereda todo| LZ["🎯 Landing Zones MG\n📜 Allowed-VM-Sizes [Deny]\n📜 Require-Backup [DINE]\n📜 Enforce-TLS [Deny]"]

    Root --&gt;|Hereda todo| Sandbox["🧪 Sandbox MG\n📜 Auto-Shutdown [DINE]\n📜 Budget-Alert [Audit]\n❌ Exemption: Public-IPs"]

    LZ --&gt;|Hereda| CorpProd["🟢 Corp-Prod\n📜 Require-Delete-Lock [Append]\n📜 Require-Monitoring [DINE]"]

    LZ --&gt;|Hereda| CorpNonProd["🟡 Corp-NonProd\n📜 Auto-Shutdown [DINE]"]

    style Root fill:#ffebee
    style Platform fill:#fff3e0
    style LZ fill:#e3f2fd
    style Sandbox fill:#e8f5e9
    style CorpProd fill:#f3e5f5
    style CorpNonProd fill:#fff9c4
</code></pre>

<p><strong>Diagrama detallado</strong>: <code class="language-plaintext highlighter-rouge">_assets/diagrams/policy-assignments-matrix.drawio</code></p>

<h2 id="-3-diseño">🎨 3. Diseño</h2>

<h3 id="naming-convention">Naming Convention</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Policy Definitions:
  Formato: pol-&lt;category&gt;-&lt;action&gt;-&lt;resource&gt;-&lt;version&gt;
  Ejemplos:
  - pol-sec-deny-publicip-v1
  - pol-net-require-nsg-subnet-v2
  - pol-tag-require-costcenter-v1
  - pol-mon-dine-diagnostics-v3

Policy Initiatives (Sets):
  Formato: ini-&lt;framework&gt;-&lt;scope&gt;
  Ejemplos:
  - ini-security-baseline-platform
  - ini-ens-esquema-nacional-seguridad
  - ini-cis-benchmark-azure
  - ini-cost-governance-landingzones

Policy Assignments:
  Formato: pa-&lt;initiative/policy&gt;-&lt;mg-scope&gt;
  Ejemplos:
  - pa-security-baseline-root
  - pa-require-tags-landingzones
  - pa-auto-shutdown-sandbox
</code></pre></div></div>

<h3 id="matriz-de-efectos-por-entorno">Matriz de Efectos por Entorno</h3>

<table>
  <thead>
    <tr>
      <th>Efecto</th>
      <th>Root MG</th>
      <th>Platform</th>
      <th>Landing Zones Prod</th>
      <th>Landing Zones NonProd</th>
      <th>Sandbox</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Deny</strong></td>
      <td>Tags, Locations</td>
      <td>PublicIP, Ports</td>
      <td>VM Sizes, SKUs</td>
      <td>Límites básicos</td>
      <td>❌ Mínimo</td>
    </tr>
    <tr>
      <td><strong>DenyAction</strong></td>
      <td>Delete locks</td>
      <td>—</td>
      <td>Delete recursos críticos</td>
      <td>—</td>
      <td>—</td>
    </tr>
    <tr>
      <td><strong>DeployIfNotExists</strong></td>
      <td>Diagnostics</td>
      <td>Private Endpoints</td>
      <td>Backup, Monitoring</td>
      <td>Monitoring</td>
      <td>Auto-Shutdown</td>
    </tr>
    <tr>
      <td><strong>Modify</strong></td>
      <td>Tag enforcement</td>
      <td>—</td>
      <td>Tag inheritance</td>
      <td>—</td>
      <td>—</td>
    </tr>
    <tr>
      <td><strong>Audit</strong></td>
      <td>ENS, CIS</td>
      <td>Encryption</td>
      <td>Compliance checks</td>
      <td>All</td>
      <td>All</td>
    </tr>
    <tr>
      <td><strong>AuditIfNotExists</strong></td>
      <td>Defender plans</td>
      <td>Key Vault logs</td>
      <td>Backup status</td>
      <td>—</td>
      <td>—</td>
    </tr>
  </tbody>
</table>

<h3 id="categorías-de-políticas">Categorías de Políticas</h3>

<table>
  <thead>
    <tr>
      <th>Categoría</th>
      <th>Descripción</th>
      <th>Iniciativa</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Security</strong></td>
      <td>Seguridad de red, endpoints, cifrado</td>
      <td><code class="language-plaintext highlighter-rouge">ini-security-baseline</code></td>
    </tr>
    <tr>
      <td><strong>Tagging</strong></td>
      <td>Etiquetado obligatorio y herencia</td>
      <td><code class="language-plaintext highlighter-rouge">ini-tagging-governance</code></td>
    </tr>
    <tr>
      <td><strong>Monitoring</strong></td>
      <td>Diagnostic settings, Log Analytics</td>
      <td><code class="language-plaintext highlighter-rouge">ini-monitoring-baseline</code></td>
    </tr>
    <tr>
      <td><strong>Cost</strong></td>
      <td>VM sizes, auto-shutdown, budgets</td>
      <td><code class="language-plaintext highlighter-rouge">ini-cost-governance</code></td>
    </tr>
    <tr>
      <td><strong>Compliance</strong></td>
      <td>ENS, CIS, NIST, ISO 27001</td>
      <td><code class="language-plaintext highlighter-rouge">ini-regulatory-compliance</code></td>
    </tr>
    <tr>
      <td><strong>Networking</strong></td>
      <td>NSG, Private Endpoints, DNS</td>
      <td><code class="language-plaintext highlighter-rouge">ini-network-governance</code></td>
    </tr>
    <tr>
      <td><strong>Backup &amp; DR</strong></td>
      <td>Azure Backup, ASR</td>
      <td><code class="language-plaintext highlighter-rouge">ini-bcdr-requirements</code></td>
    </tr>
    <tr>
      <td><strong>Identity</strong></td>
      <td>MFA, PIM, RBAC</td>
      <td><code class="language-plaintext highlighter-rouge">ini-identity-governance</code></td>
    </tr>
  </tbody>
</table>

<h2 id="-4-observabilidad">📊 4. Observabilidad</h2>

<h3 id="compliance-dashboard">Compliance Dashboard</h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Alertas_Compliance"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"compliance_drop"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"metric"</span><span class="p">:</span><span class="w"> </span><span class="s2">"NonCompliantResources"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"threshold"</span><span class="p">:</span><span class="w"> </span><span class="s2">"20% increase in 24h"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"High"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"notification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"governance-team@contoso.com"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"new_deny_triggered"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Authorization/policyAssignments/deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Medium"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"notification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"platform-team@contoso.com"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"exemption_expiring"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"check"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Exemptions expiring in 7 days"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Medium"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"notification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"compliance-team@contoso.com"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"remediation_failed"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"operation"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.PolicyInsights/remediations/write"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Failed"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"High"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"notification"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sre-team@contoso.com"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="kpis-de-gobernanza">KPIs de Gobernanza</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Métricas clave a monitorear semanalmente:

├── Overall Compliance Rate
│   ├── Target: &gt; 95% en Production MGs
│   ├── Target: &gt; 80% en NonProd MGs
│   └── Target: &gt; 60% en Sandbox
│
├── Non-Compliant Resources por categoría
│   ├── Security (crítico - resolver en &lt;24h)
│   ├── Tagging (alto - resolver en &lt;72h)
│   └── Monitoring (medio - resolver en &lt;1 semana)
│
├── Policy Exemptions activas
│   ├── Count total (target: &lt; 20 activas)
│   ├── Próximas a expirar (&lt; 7 días)
│   └── Sin fecha de expiración (target: 0)
│
├── Remediation Tasks
│   ├── Pendientes
│   ├── En progreso
│   ├── Completadas (última semana)
│   └── Fallidas (requiere atención)
│
└── EPAC Drift Detection
    ├── Políticas fuera de source control
    ├── Assignments sin definición en repo
    └── Exemptions no registradas en Git
</code></pre></div></div>

<h3 id="logs-y-diagnósticos">Logs y Diagnósticos</h3>

<ul>
  <li><strong>Activity Logs</strong>: Todas las operaciones de policy (create, modify, delete, deny triggered)
    <ul>
      <li>Retención: 90 días en Log Analytics</li>
      <li>Alertas en Azure Monitor para cambios críticos</li>
    </ul>
  </li>
  <li><strong>Policy Insights</strong>: Estado de compliance en tiempo real
    <ul>
      <li>Dashboard URL: <code class="language-plaintext highlighter-rouge">https://portal.azure.com/#view/Microsoft_Azure_Policy/PolicyComplianceBlade</code></li>
      <li>Export a Storage Account para análisis histórico</li>
    </ul>
  </li>
  <li><strong>AzAdvertizer</strong>: Herramienta externa para explorar y visualizar políticas
    <ul>
      <li>URL: <code class="language-plaintext highlighter-rouge">https://www.azadvertizer.net/</code></li>
    </ul>
  </li>
</ul>

<h3 id="backup-y-dr">Backup y DR</h3>

<ul>
  <li><strong>Todo como código</strong>: EPAC en repositorio Git con ramas <code class="language-plaintext highlighter-rouge">main</code>, <code class="language-plaintext highlighter-rouge">staging</code>, <code class="language-plaintext highlighter-rouge">dev</code></li>
  <li><strong>State management</strong>: EPAC usa Azure Storage como backend de estado</li>
  <li><strong>Rollback</strong>: Revert en Git + <code class="language-plaintext highlighter-rouge">epac deploy</code> restaura estado anterior en minutos
    <ul>
      <li>RPO: 1 hora (commits frecuentes)</li>
      <li>RTO: 30 minutos (re-despliegue EPAC)</li>
    </ul>
  </li>
</ul>

<h2 id="️-5-implementación-iac">⚙️ 5. Implementación (IaC)</h2>

<h3 id="estructura-epac-enterprise-policy-as-code">Estructura EPAC (Enterprise Policy as Code)</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>epac-root/
├── .github/
│   └── workflows/
│       ├── epac-plan.yml          # PR: valida cambios
│       └── epac-deploy.yml        # main: despliega
├── Definitions/
│   ├── policyDefinitions/
│   │   ├── Security/
│   │   │   ├── pol-deny-publicip.jsonc
│   │   │   ├── pol-require-nsg-subnet.jsonc
│   │   │   └── pol-enforce-tls-storage.jsonc
│   │   ├── Tagging/
│   │   │   ├── pol-require-tag-costcenter.jsonc
│   │   │   └── pol-inherit-tags-rg.jsonc
│   │   ├── Monitoring/
│   │   │   ├── pol-dine-diagnostics-vm.jsonc
│   │   │   └── pol-dine-diagnostics-storage.jsonc
│   │   ├── Cost/
│   │   │   ├── pol-deny-vm-size.jsonc
│   │   │   └── pol-dine-autoshutdown.jsonc
│   │   └── Networking/
│   │       ├── pol-require-private-endpoint.jsonc
│   │       └── pol-manage-nsg-subnet.jsonc
│   ├── policySetDefinitions/
│   │   ├── ini-security-baseline.jsonc
│   │   ├── ini-tagging-governance.jsonc
│   │   ├── ini-monitoring-baseline.jsonc
│   │   ├── ini-cost-governance.jsonc
│   │   ├── ini-ens-spain.jsonc
│   │   └── ini-network-governance.jsonc
│   └── global-settings.jsonc
├── Assignments/
│   ├── root-mg/
│   │   ├── pa-security-baseline.jsonc
│   │   ├── pa-tagging-governance.jsonc
│   │   └── pa-monitoring-baseline.jsonc
│   ├── platform-mg/
│   │   └── pa-network-governance.jsonc
│   ├── landingzones-mg/
│   │   ├── pa-cost-governance.jsonc
│   │   └── pa-bcdr-requirements.jsonc
│   └── sandbox-mg/
│       └── pa-sandbox-governance.jsonc
├── Exemptions/
│   └── sandbox/
│       └── exempt-publicip-sandbox.jsonc
└── README.md
</code></pre></div></div>

<h3 id="terraform---módulo-de-azure-policy">Terraform - Módulo de Azure Policy</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># terraform/modules/azure-policy/main.tf</span>

<span class="nx">terraform</span> <span class="p">{</span>
  <span class="nx">required_version</span> <span class="o">=</span> <span class="s2">"&gt;= 1.5.0"</span>
  <span class="nx">required_providers</span> <span class="p">{</span>
    <span class="nx">azurerm</span> <span class="o">=</span> <span class="p">{</span>
      <span class="nx">source</span>  <span class="o">=</span> <span class="s2">"hashicorp/azurerm"</span>
      <span class="nx">version</span> <span class="o">=</span> <span class="s2">"&gt;= 3.90.0, &lt; 5.0.0"</span>
    <span class="p">}</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Custom Policy Definition</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_policy_definition"</span> <span class="s2">"this"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">custom_policy_definitions</span> <span class="o">:</span> <span class="p">{}</span>

  <span class="nx">name</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="nx">display_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">display_name</span>
  <span class="nx">description</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">description</span>
  <span class="nx">policy_type</span>  <span class="o">=</span> <span class="s2">"Custom"</span>
  <span class="nx">mode</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">mode</span> <span class="c1"># "All" | "Indexed" | "Microsoft.Kubernetes.Data" etc.</span>

  <span class="nx">management_group_id</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">management_group_id</span>

  <span class="nx">metadata</span> <span class="o">=</span> <span class="nx">jsonencode</span><span class="p">({</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">category</span>
    <span class="nx">version</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">version</span>
  <span class="p">})</span>

  <span class="nx">parameters</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span><span class="p">)</span> <span class="o">:</span> <span class="kc">null</span>
  <span class="nx">policy_rule</span> <span class="o">=</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_rule</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Policy Initiative (Set Definition)</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_policy_set_definition"</span> <span class="s2">"this"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">policy_initiatives</span> <span class="o">:</span> <span class="p">{}</span>

  <span class="nx">name</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="nx">display_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">display_name</span>
  <span class="nx">description</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">description</span>
  <span class="nx">policy_type</span>  <span class="o">=</span> <span class="s2">"Custom"</span>

  <span class="nx">management_group_id</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">management_group_id</span>

  <span class="nx">metadata</span> <span class="o">=</span> <span class="nx">jsonencode</span><span class="p">({</span>
    <span class="nx">category</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">category</span>
    <span class="nx">version</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">version</span>
  <span class="p">})</span>

  <span class="nx">parameters</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span><span class="p">)</span> <span class="o">:</span> <span class="kc">null</span>

  <span class="nx">dynamic</span> <span class="s2">"policy_definition_reference"</span> <span class="p">{</span>
    <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_references</span>
    <span class="nx">content</span> <span class="p">{</span>
      <span class="nx">policy_definition_id</span> <span class="o">=</span> <span class="nx">policy_definition_reference</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_definition_id</span>
      <span class="nx">reference_id</span>         <span class="o">=</span> <span class="nx">policy_definition_reference</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">reference_id</span>
      <span class="nx">parameter_values</span>     <span class="o">=</span> <span class="nx">policy_definition_reference</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameter_values</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">policy_definition_reference</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameter_values</span><span class="p">)</span> <span class="o">:</span> <span class="kc">null</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span><span class="nx">azurerm_policy_definition</span><span class="p">.</span><span class="nx">this</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Policy Assignment (con soporte para DINE/Modify → Managed Identity)</span>
<span class="c1"># DEPENDENCY: El scope (MG/Subscription/RG) debe existir previamente</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_management_group_policy_assignment"</span> <span class="s2">"this"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">policy_assignments</span> <span class="o">:</span> <span class="p">{})</span> <span class="o">:</span>
    <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span> <span class="nx">if</span> <span class="nx">v</span><span class="p">.</span><span class="nx">scope_type</span> <span class="o">==</span> <span class="s2">"management_group"</span>
  <span class="p">}</span>

  <span class="nx">name</span>                 <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="nx">display_name</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">display_name</span>
  <span class="nx">description</span>          <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">description</span>
  <span class="nx">management_group_id</span>  <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">scope_id</span>
  <span class="nx">policy_definition_id</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_definition_id</span>
  <span class="nx">enforce</span>              <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">enforce</span> <span class="c1"># false = "DoNotEnforce" (audit-only mode)</span>
  <span class="nx">not_scopes</span>           <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">not_scopes</span>

  <span class="nx">parameters</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">parameters</span><span class="p">)</span> <span class="o">:</span> <span class="kc">null</span>

  <span class="c1"># Managed Identity requerida para efectos DINE y Modify</span>
  <span class="nx">dynamic</span> <span class="s2">"identity"</span> <span class="p">{</span>
    <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">identity_type</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">:</span> <span class="p">[]</span>
    <span class="nx">content</span> <span class="p">{</span>
      <span class="nx">type</span>         <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">identity_type</span>
      <span class="nx">identity_ids</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">identity_type</span> <span class="o">==</span> <span class="s2">"UserAssigned"</span> <span class="o">?</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">identity_ids</span> <span class="o">:</span> <span class="kc">null</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">location</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">identity_type</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">location</span> <span class="o">:</span> <span class="kc">null</span>

  <span class="nx">dynamic</span> <span class="s2">"non_compliance_message"</span> <span class="p">{</span>
    <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">non_compliance_messages</span>
    <span class="nx">content</span> <span class="p">{</span>
      <span class="nx">content</span>                        <span class="o">=</span> <span class="nx">non_compliance_message</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">content</span>
      <span class="nx">policy_definition_reference_id</span> <span class="o">=</span> <span class="nx">non_compliance_message</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">reference_id</span>
    <span class="p">}</span>
  <span class="p">}</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_policy_definition</span><span class="p">.</span><span class="nx">this</span><span class="p">,</span>
    <span class="nx">azurerm_policy_set_definition</span><span class="p">.</span><span class="nx">this</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Role Assignment para Managed Identity de la Policy</span>
<span class="c1"># DEPENDENCY: Requiere que el assignment con managed identity ya exista</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_role_assignment"</span> <span class="s2">"policy_remediation"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">policy_assignments</span> <span class="o">:</span> <span class="p">{})</span> <span class="o">:</span>
    <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span> <span class="nx">if</span> <span class="nx">v</span><span class="p">.</span><span class="nx">identity_type</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">&amp;&amp;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">remediation_role</span> <span class="o">!=</span> <span class="kc">null</span>
  <span class="p">}</span>

  <span class="nx">scope</span>                <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">remediation_scope</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">remediation_scope</span> <span class="o">:</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">scope_id</span>
  <span class="nx">role_definition_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">remediation_role</span>
  <span class="nx">principal_id</span>         <span class="o">=</span> <span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">identity</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">principal_id</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span><span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span><span class="p">]</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Remediation Task (para recursos ya existentes no conformes)</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_management_group_policy_remediation"</span> <span class="s2">"this"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span><span class="p">(</span><span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">policy_assignments</span> <span class="o">:</span> <span class="p">{})</span> <span class="o">:</span>
    <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span> <span class="nx">if</span> <span class="nx">v</span><span class="p">.</span><span class="nx">create_remediation_task</span> <span class="o">==</span> <span class="kc">true</span>
  <span class="p">}</span>

  <span class="nx">name</span>                    <span class="o">=</span> <span class="s2">"remediation-${each.key}"</span>
  <span class="nx">management_group_id</span>     <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">scope_id</span>
  <span class="nx">policy_assignment_id</span>    <span class="o">=</span> <span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">id</span>
  <span class="nx">resource_discovery_mode</span> <span class="o">=</span> <span class="s2">"ReEvaluateCompliance"</span>

  <span class="nx">depends_on</span> <span class="o">=</span> <span class="p">[</span>
    <span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span><span class="p">,</span>
    <span class="nx">azurerm_role_assignment</span><span class="p">.</span><span class="nx">policy_remediation</span>
  <span class="p">]</span>
<span class="p">}</span>

<span class="c1"># ─────────────────────────────────────────────</span>
<span class="c1"># Policy Exemption</span>
<span class="c1"># DEPENDENCY: El assignment al que se exime debe existir</span>
<span class="c1"># ─────────────────────────────────────────────</span>
<span class="nx">resource</span> <span class="s2">"azurerm_management_group_policy_exemption"</span> <span class="s2">"this"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">create</span> <span class="o">?</span> <span class="nx">var</span><span class="p">.</span><span class="nx">policy_exemptions</span> <span class="o">:</span> <span class="p">{}</span>

  <span class="nx">name</span>                            <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">key</span>
  <span class="nx">display_name</span>                    <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">display_name</span>
  <span class="nx">description</span>                     <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">description</span>
  <span class="nx">management_group_id</span>             <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">scope_id</span>
  <span class="nx">policy_assignment_id</span>            <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_assignment_id</span>
  <span class="nx">exemption_category</span>              <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">exemption_category</span> <span class="c1"># "Waiver" | "Mitigated"</span>
  <span class="nx">expires_on</span>                      <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">expires_on</span>         <span class="c1"># ISO 8601, obligatorio en este módulo</span>
  <span class="nx">policy_definition_reference_ids</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">policy_definition_reference_ids</span>
  <span class="nx">metadata</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">metadata</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="nx">jsonencode</span><span class="p">(</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">metadata</span><span class="p">)</span> <span class="o">:</span> <span class="nx">jsonencode</span><span class="p">({</span>
    <span class="nx">approvedBy</span>   <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">approved_by</span>
    <span class="nx">ticketNumber</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">ticket_number</span>
    <span class="nx">reviewDate</span>   <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">review_date</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># terraform/modules/azure-policy/variables.tf</span>

<span class="nx">variable</span> <span class="s2">"create"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Controla si el módulo crea recursos."</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">bool</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"custom_policy_definitions"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de custom policy definitions a crear."</span>
  <span class="nx">type</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
    <span class="nx">display_name</span>        <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">description</span>         <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">mode</span>                <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">category</span>            <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">version</span>             <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">management_group_id</span> <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">parameters</span>          <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">any</span><span class="err">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">policy_rule</span>         <span class="o">=</span> <span class="nx">any</span>
  <span class="p">}))</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"policy_initiatives"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de policy initiatives (set definitions) a crear."</span>
  <span class="nx">type</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
    <span class="nx">display_name</span>        <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">description</span>         <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">category</span>            <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">version</span>             <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">management_group_id</span> <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">parameters</span>          <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">any</span><span class="err">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">policy_references</span> <span class="o">=</span> <span class="nx">list</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
      <span class="nx">policy_definition_id</span> <span class="o">=</span> <span class="nx">string</span>
      <span class="nx">reference_id</span>         <span class="o">=</span> <span class="nx">string</span>
      <span class="nx">parameter_values</span>     <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">any</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="p">}))</span>
  <span class="p">}))</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"policy_assignments"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de policy assignments a Management Groups."</span>
  <span class="nx">type</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
    <span class="nx">display_name</span>         <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">description</span>          <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">scope_type</span>           <span class="o">=</span> <span class="nx">string</span>  <span class="c1"># "management_group"</span>
    <span class="nx">scope_id</span>             <span class="o">=</span> <span class="nx">string</span>  <span class="c1"># DEPENDENCY: MG debe existir</span>
    <span class="nx">policy_definition_id</span> <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">enforce</span>              <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">bool</span><span class="err">,</span> <span class="kc">true</span><span class="p">)</span>
    <span class="nx">not_scopes</span>           <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">),</span> <span class="p">[])</span>
    <span class="nx">parameters</span>           <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">any</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">identity_type</span>        <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">string</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>   <span class="c1"># null | "SystemAssigned" | "UserAssigned"</span>
    <span class="nx">identity_ids</span>         <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">list</span><span class="p">(</span><span class="nx">string</span><span class="p">),</span> <span class="p">[])</span>
    <span class="nx">location</span>             <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">string</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>   <span class="c1"># Requerido si identity_type != null</span>
    <span class="nx">remediation_role</span>     <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">string</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">remediation_scope</span>    <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">string</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">create_remediation_task</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">bool</span><span class="p">,</span> <span class="kc">false</span><span class="p">)</span>
    <span class="nx">non_compliance_messages</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">list</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
      <span class="nx">content</span>      <span class="o">=</span> <span class="nx">string</span>
      <span class="nx">reference_id</span> <span class="o">=</span> <span class="nx">optional</span><span class="p">(</span><span class="nx">string</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="p">})),</span> <span class="p">[])</span>
  <span class="p">}))</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"policy_exemptions"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de exemptions. TODAS deben tener expires_on y ticket de aprobación."</span>
  <span class="nx">type</span> <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">object</span><span class="p">({</span>
    <span class="nx">display_name</span>                    <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">description</span>                     <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">scope_id</span>                        <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">policy_assignment_id</span>            <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">exemption_category</span>              <span class="o">=</span> <span class="nx">string</span>  <span class="c1"># "Waiver" | "Mitigated"</span>
    <span class="nx">expires_on</span>                      <span class="o">=</span> <span class="nx">string</span>  <span class="c1"># ISO 8601 - OBLIGATORIO</span>
    <span class="nx">policy_definition_reference_ids</span> <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">list</span><span class="err">(</span><span class="nx">string</span><span class="p">),</span> <span class="p">[])</span>
    <span class="nx">approved_by</span>                     <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">ticket_number</span>                   <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">review_date</span>                     <span class="o">=</span> <span class="nx">string</span>
    <span class="nx">metadata</span>                        <span class="o">=</span> <span class="nx">optional</span><span class="err">(</span><span class="nx">any</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
  <span class="p">}))</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>

<span class="nx">variable</span> <span class="s2">"tags"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Tags aplicados a recursos que lo soportan."</span>
  <span class="nx">type</span>        <span class="o">=</span> <span class="nx">map</span><span class="p">(</span><span class="nx">string</span><span class="p">)</span>
  <span class="nx">default</span>     <span class="o">=</span> <span class="p">{}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># terraform/modules/azure-policy/outputs.tf</span>

<span class="nx">output</span> <span class="s2">"policy_definition_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de IDs de custom policy definitions creadas."</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_policy_definition</span><span class="p">.</span><span class="nx">this</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"initiative_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de IDs de policy initiatives creadas."</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_policy_set_definition</span><span class="p">.</span><span class="nx">this</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"assignment_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de IDs de policy assignments."</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"assignment_principal_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Principal IDs de las Managed Identities de los assignments (para RBAC externo)."</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_management_group_policy_assignment</span><span class="p">.</span><span class="nx">this</span> <span class="o">:</span>
    <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">try</span><span class="p">(</span><span class="nx">v</span><span class="p">.</span><span class="nx">identity</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">principal_id</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span>
    <span class="nx">if</span> <span class="nx">try</span><span class="p">(</span><span class="nx">v</span><span class="p">.</span><span class="nx">identity</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">principal_id</span><span class="p">,</span> <span class="kc">null</span><span class="p">)</span> <span class="o">!</span><span class="p">=</span> <span class="kc">null</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="nx">output</span> <span class="s2">"exemption_ids"</span> <span class="p">{</span>
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Mapa de IDs de policy exemptions."</span>
  <span class="nx">value</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">k</span><span class="p">,</span> <span class="nx">v</span> <span class="nx">in</span> <span class="nx">azurerm_management_group_policy_exemption</span><span class="p">.</span><span class="nx">this</span> <span class="o">:</span> <span class="nx">k</span> <span class="o">=&gt;</span> <span class="nx">v</span><span class="p">.</span><span class="nx">id</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="ejemplo-de-custom-policy-definition-deny-public-ip">Ejemplo de Custom Policy Definition (Deny Public IP)</h3>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Definitions/policyDefinitions/Security/pol-deny-publicip.jsonc</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pol-sec-deny-publicip-v1"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Authorization/policyDefinitions"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[Contoso] Deny creation of Public IP Addresses"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Denies creation of Public IP resources except for approved SKUs."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"policyType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Custom"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"All"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Security"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"preview"</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"String"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"allowedValues"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Deny"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Audit"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Disabled"</span><span class="p">],</span><span class="w">
        </span><span class="nl">"defaultValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Effect"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny | Audit | Disabled"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"allowedSKUs"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Array"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"defaultValue"</span><span class="p">:</span><span class="w"> </span><span class="p">[],</span><span class="w">
        </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Allowed Public IP SKUs"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Empty array = deny all. Add SKUs to allow specific ones."</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"policyRule"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"if"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"allOf"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"type"</span><span class="p">,</span><span class="w">
            </span><span class="nl">"equals"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Network/publicIPAddresses"</span><span class="w">
          </span><span class="p">},</span><span class="w">
          </span><span class="p">{</span><span class="w">
            </span><span class="nl">"not"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
              </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Network/publicIPAddresses/sku.name"</span><span class="p">,</span><span class="w">
              </span><span class="nl">"in"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[parameters('allowedSKUs')]"</span><span class="w">
            </span><span class="p">}</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">]</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"then"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[parameters('effect')]"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="ejemplo-de-policy-dine---diagnostic-settings">Ejemplo de Policy DINE - Diagnostic Settings</h3>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Definitions/policyDefinitions/Monitoring/pol-dine-diagnostics-storage.jsonc</span><span class="w">
</span><span class="p">{</span><span class="w">
  </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"pol-mon-dine-diagnostics-storage-v2"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[Contoso] Deploy Diagnostic Settings for Storage Accounts"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deploys diagnostic settings to Storage Accounts sending logs to central Log Analytics Workspace."</span><span class="p">,</span><span class="w">
    </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Indexed"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Monitoring"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2.0.0"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"String"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"allowedValues"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"DeployIfNotExists"</span><span class="p">,</span><span class="w"> </span><span class="s2">"AuditIfNotExists"</span><span class="p">,</span><span class="w"> </span><span class="s2">"Disabled"</span><span class="p">],</span><span class="w">
        </span><span class="nl">"defaultValue"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DeployIfNotExists"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"logAnalyticsWorkspaceId"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"String"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"metadata"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Log Analytics Workspace Resource ID"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DEPENDENCY: Must be an existing Log Analytics Workspace."</span><span class="p">,</span><span class="w">
          </span><span class="nl">"strongType"</span><span class="p">:</span><span class="w"> </span><span class="s2">"omsWorkspace"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="nl">"policyRule"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"if"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"field"</span><span class="p">:</span><span class="w"> </span><span class="s2">"type"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"equals"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Storage/storageAccounts"</span><span class="w">
      </span><span class="p">},</span><span class="w">
      </span><span class="nl">"then"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[parameters('effect')]"</span><span class="p">,</span><span class="w">
        </span><span class="nl">"details"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Insights/diagnosticSettings"</span><span class="p">,</span><span class="w">
          </span><span class="nl">"roleDefinitionIds"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
            </span><span class="s2">"/providers/Microsoft.Authorization/roleDefinitions/92aaf0da-9dab-42b6-94a3-d43ce8d16293"</span><span class="w">
          </span><span class="p">],</span><span class="w">
          </span><span class="nl">"deployment"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
            </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
              </span><span class="nl">"mode"</span><span class="p">:</span><span class="w"> </span><span class="s2">"incremental"</span><span class="p">,</span><span class="w">
              </span><span class="nl">"template"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"$schema"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"contentVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0.0"</span><span class="p">,</span><span class="w">
                </span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                  </span><span class="nl">"resourceName"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="w"> </span><span class="p">},</span><span class="w">
                  </span><span class="nl">"workspaceId"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"string"</span><span class="w"> </span><span class="p">}</span><span class="w">
                </span><span class="p">},</span><span class="w">
                </span><span class="nl">"resources"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                  </span><span class="p">{</span><span class="w">
                    </span><span class="nl">"type"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Storage/storageAccounts/providers/diagnosticSettings"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"apiVersion"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2021-05-01-preview"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[concat(parameters('resourceName'), '/Microsoft.Insights/setByPolicy')]"</span><span class="p">,</span><span class="w">
                    </span><span class="nl">"properties"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                      </span><span class="nl">"workspaceId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[parameters('workspaceId')]"</span><span class="p">,</span><span class="w">
                      </span><span class="nl">"logs"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="p">{</span><span class="w"> </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"StorageRead"</span><span class="p">,</span><span class="w"> </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">},</span><span class="w">
                        </span><span class="p">{</span><span class="w"> </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"StorageWrite"</span><span class="p">,</span><span class="w"> </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">},</span><span class="w">
                        </span><span class="p">{</span><span class="w"> </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"StorageDelete"</span><span class="p">,</span><span class="w"> </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w">
                      </span><span class="p">],</span><span class="w">
                      </span><span class="nl">"metrics"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
                        </span><span class="p">{</span><span class="w"> </span><span class="nl">"category"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Transaction"</span><span class="p">,</span><span class="w"> </span><span class="nl">"enabled"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}</span><span class="w">
                      </span><span class="p">]</span><span class="w">
                    </span><span class="p">}</span><span class="w">
                  </span><span class="p">}</span><span class="w">
                </span><span class="p">]</span><span class="w">
              </span><span class="p">},</span><span class="w">
              </span><span class="nl">"parameters"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
                </span><span class="nl">"resourceName"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[field('name')]"</span><span class="w"> </span><span class="p">},</span><span class="w">
                </span><span class="nl">"workspaceId"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"value"</span><span class="p">:</span><span class="w"> </span><span class="s2">"[parameters('logAnalyticsWorkspaceId')]"</span><span class="w"> </span><span class="p">}</span><span class="w">
              </span><span class="p">}</span><span class="w">
            </span><span class="p">}</span><span class="w">
          </span><span class="p">}</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="pipeline-epac-github-actions">Pipeline EPAC (GitHub Actions)</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/epac-plan.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">EPAC Plan (PR Validation)</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Definitions/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Assignments/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Exemptions/**'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">epac-plan</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">write</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login (OIDC)</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">client-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tenant-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">subscription-id</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install EPAC</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">Install-Module EnterprisePolicyAsCode -Force -AllowClobber</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">EPAC Build-DeploymentPlans</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">Build-DeploymentPlans `</span>
            <span class="s">-DefinitionsRootFolder ./Definitions `</span>
            <span class="s">-OutputFolder ./Output `</span>
            <span class="s">-GlobalSettingsFile ./Definitions/global-settings.jsonc</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">PSRule Validation</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">microsoft/psrule-action@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">inputPath</span><span class="pi">:</span> <span class="s1">'</span><span class="s">./Output'</span>
          <span class="na">modules</span><span class="pi">:</span> <span class="s1">'</span><span class="s">PSRule.Rules.Azure'</span>
          <span class="na">option</span><span class="pi">:</span> <span class="s1">'</span><span class="s">.ps-rule/ps-rule.yaml'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Comment PR with Plan</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/github-script@v7</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
            <span class="s">const fs = require('fs');</span>
            <span class="s">const plan = fs.readFileSync('./Output/plan-summary.md', 'utf8');</span>
            <span class="s">github.rest.issues.createComment({</span>
              <span class="s">issue_number: context.issue.number,</span>
              <span class="s">owner: context.repo.owner,</span>
              <span class="s">repo: context.repo.repo,</span>
              <span class="s">body: `## EPAC Deployment Plan\n\n${plan}`</span>
            <span class="s">});</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/epac-deploy.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">EPAC Deploy (main branch)</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Definitions/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Assignments/**'</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">Exemptions/**'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">deploy-definitions</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Policy Definitions</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">production</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">client-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tenant-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">subscription-id</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install EPAC</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">Install-Module EnterprisePolicyAsCode -Force</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Policy Definitions</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">Deploy-PolicyPlan `</span>
            <span class="s">-DefinitionsRootFolder ./Definitions `</span>
            <span class="s">-PlanFile ./Output/policy-plan.json</span>

  <span class="na">deploy-assignments</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Policy Assignments</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">deploy-definitions</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span> <span class="s">production</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v2</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">client-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">tenant-id</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">subscription-id</span><span class="pi">:</span> <span class="s">$</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Assignments</span>
        <span class="na">shell</span><span class="pi">:</span> <span class="s">pwsh</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">Deploy-RolesPlan `</span>
            <span class="s">-DefinitionsRootFolder ./Definitions `</span>
            <span class="s">-PlanFile ./Output/roles-plan.json</span>
</code></pre></div></div>

<h2 id="-6-seguridad">🔒 6. Seguridad</h2>

<h3 id="políticas-críticas-por-dominio">Políticas Críticas por Dominio</h3>

<h4 id="seguridad-de-red">Seguridad de Red</h4>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"network_policies"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny-Public-IPs"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Platform MG"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Previene exposición accidental a internet"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Require-NSG-Subnet"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Landing Zones MG"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Toda subnet debe tener NSG asociado"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"ref"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://techcommunity.microsoft.com/blog/azurenetworkingblog/manage-nsg-association-on-subnets-via-azure-policy/4243157"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Enforce-Private-Endpoints"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Platform + Corp Prod"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Storage, SQL, KeyVault deben usar Private Endpoints"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DNS-Private-Endpoint"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DeployIfNotExists"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Landing Zones MG"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Registra automáticamente Private Endpoints en DNS privado"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h4 id="cumplimiento-ens-españa">Cumplimiento ENS (España)</h4>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"ens_initiative"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ini-ens-spain"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"displayName"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Spain ENS - Esquema Nacional de Seguridad"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Initiative aligning with Spain's National Security Framework"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"ref"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://learn.microsoft.com/es-es/azure/governance/policy/samples/spain-ens"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"categories"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
      </span><span class="s2">"Acceso al sistema"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Gestión de activos"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Continuidad del negocio"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Control de acceso"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Criptografía"</span><span class="p">,</span><span class="w">
      </span><span class="s2">"Auditoría y responsabilidad"</span><span class="w">
    </span><span class="p">]</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h4 id="seguridad-de-datos">Seguridad de Datos</h4>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"data_security_policies"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Require-Encryption-At-Rest"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"resources"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Storage"</span><span class="p">,</span><span class="w"> </span><span class="s2">"SQL"</span><span class="p">,</span><span class="w"> </span><span class="s2">"CosmosDB"</span><span class="p">,</span><span class="w"> </span><span class="s2">"KeyVault"</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Enforce-TLS-1.2-Minimum"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"resources"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"Storage"</span><span class="p">,</span><span class="w"> </span><span class="s2">"AppService"</span><span class="p">,</span><span class="w"> </span><span class="s2">"APIManagement"</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny-HTTP-AppService"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Deny"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Fuerza HTTPS en todas las App Services"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="denyaction---protección-contra-borrado">DenyAction - Protección contra Borrado</h3>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"denyaction_policies"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DenyAction-Delete-Recovery-Vault"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DenyAction"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"delete"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"resource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.RecoveryServices/vaults"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Previene borrado accidental de Recovery Services Vaults"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"ref"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://journeyofthegeek.com/2024/05/08/azure-authorization-resource-locks-and-azure-policy-denyactions/"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DenyAction-Delete-Network-Hub"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"effect"</span><span class="p">:</span><span class="w"> </span><span class="s2">"DenyAction"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"delete"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"resource"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Microsoft.Network/virtualNetworks"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"scope"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Connectivity MG"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="govern-de-identidad-y-rbac-para-policy">Govern de Identidad y RBAC para Policy</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">Policy Management Permissions</span><span class="pi">:</span>
  <span class="na">Root MG</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">Resource Policy Contributor</span><span class="pi">:</span> <span class="s">Governance-Team-Group</span>
    <span class="pi">-</span> <span class="na">Policy Insights Data Writer</span><span class="pi">:</span> <span class="s">Automation-Service-Principal</span>

  <span class="na">All MGs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">Reader</span><span class="pi">:</span> <span class="s">Security-Team (para auditoría de compliance)</span>

  <span class="na">Remediation Managed Identities (por assignment)</span><span class="pi">:</span>
    <span class="na">DINE-Diagnostics</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">Role</span><span class="pi">:</span> <span class="s">Log Analytics Contributor</span>
      <span class="pi">-</span> <span class="na">Scope</span><span class="pi">:</span> <span class="s">Landing Zones MG</span>

    <span class="na">DINE-Backup</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">Role</span><span class="pi">:</span> <span class="s">Backup Contributor</span>
      <span class="pi">-</span> <span class="na">Scope</span><span class="pi">:</span> <span class="s">Corp-Prod MG</span>

    <span class="na">Modify-Tags</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">Role</span><span class="pi">:</span> <span class="s">Tag Contributor</span>
      <span class="pi">-</span> <span class="na">Scope</span><span class="pi">:</span> <span class="s">Root MG</span>

<span class="s">Custom Role "Policy Exemption Manager"</span><span class="err">:</span>
  <span class="na">Actions</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">Microsoft.Authorization/policyExemptions/write</span>
    <span class="pi">-</span> <span class="s">Microsoft.Authorization/policyExemptions/delete</span>
    <span class="pi">-</span> <span class="s">Microsoft.Authorization/policyExemptions/read</span>
  <span class="na">AssignableScope</span><span class="pi">:</span> <span class="s">Root MG</span>
  <span class="na">Principals</span><span class="pi">:</span> <span class="s">Governance-Team-Group (via PIM)</span>
</code></pre></div></div>

<h2 id="-7-costes">💰 7. Costes</h2>

<h3 id="modelo-de-costes-de-azure-policy">Modelo de Costes de Azure Policy</h3>

<table>
  <thead>
    <tr>
      <th>Rango de Recursos</th>
      <th>Coste por operación</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Primeros 1,000 recursos</td>
      <td><strong>Gratuito</strong></td>
    </tr>
    <tr>
      <td>1,001 – 10,000 recursos</td>
      <td>$0.50 / 100 recursos evaluados</td>
    </tr>
    <tr>
      <td>10,001 – 100,000 recursos</td>
      <td>$0.30 / 100 recursos evaluados</td>
    </tr>
    <tr>
      <td>&gt; 100,000 recursos</td>
      <td>$0.20 / 100 recursos evaluados</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>Nota</strong>: Solo cuentan evaluaciones de políticas con Guest Configuration, las demás son gratuitas. Las políticas de Regulatory Compliance (ENS, CIS, etc.) pueden tener coste.</p>
</blockquote>

<h3 id="estimación-para-enterprise-500-suscripciones">Estimación para Enterprise (500+ suscripciones)</h3>

<table>
  <thead>
    <tr>
      <th>Componente</th>
      <th>Coste Mensual Estimado</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Azure Policy (evaluaciones)</td>
      <td>~$200/mes</td>
    </tr>
    <tr>
      <td>Log Analytics (compliance logs)</td>
      <td>~$150/mes</td>
    </tr>
    <tr>
      <td>EPAC Storage Account</td>
      <td>~$5/mes</td>
    </tr>
    <tr>
      <td>GitHub Actions (CI/CD)</td>
      <td>~$20/mes</td>
    </tr>
    <tr>
      <td><strong>Total</strong></td>
      <td><strong>~$375/mes</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="roi-de-gobernanza-via-policy">ROI de Gobernanza via Policy</h3>

<table>
  <thead>
    <tr>
      <th>Ahorro</th>
      <th>Mecanismo</th>
      <th>Impacto Anual</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Prevención de over-provisioning</strong></td>
      <td>Deny VM sizes costosas</td>
      <td>$45,000</td>
    </tr>
    <tr>
      <td><strong>Auto-shutdown dev/test</strong></td>
      <td>DINE auto-shutdown 18:00</td>
      <td>$28,000</td>
    </tr>
    <tr>
      <td><strong>Prevención de recursos huérfanos</strong></td>
      <td>Require-Tags → visibilidad</td>
      <td>$15,000</td>
    </tr>
    <tr>
      <td><strong>Remediación automática</strong></td>
      <td>DINE vs remediación manual</td>
      <td>$20,000 (tiempo técnico)</td>
    </tr>
    <tr>
      <td><strong>Compliance automático</strong></td>
      <td>Evitar multas/auditorías</td>
      <td>$50,000+</td>
    </tr>
    <tr>
      <td><strong>Total estimado</strong></td>
      <td> </td>
      <td><strong>~$158,000/año</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="cost-governance-via-policy">Cost Governance via Policy</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Policy que fuerza tags de CostCenter en todos los recursos</span>
az policy assignment create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"require-costcenter-tag"</span> <span class="se">\</span>
  <span class="nt">--display-name</span> <span class="s2">"Require CostCenter tag"</span> <span class="se">\</span>
  <span class="nt">--policy</span> <span class="s2">"/providers/Microsoft.Authorization/policyDefinitions/1e30110a-5ceb-460c-a204-c1c3969c6d62"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span> <span class="se">\</span>
  <span class="nt">--params</span> <span class="s1">'{"tagName": {"value": "CostCenter"}}'</span>

<span class="c"># Policy que hereda tags del Resource Group a recursos hijos</span>
az policy assignment create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"inherit-rg-tags"</span> <span class="se">\</span>
  <span class="nt">--policy-set-definition</span> <span class="s2">"Inherit a tag from the resource group"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span>
</code></pre></div></div>

<h2 id="-8-pruebas-y-validación">🧪 8. Pruebas y Validación</h2>

<h3 id="azure-cli">Azure CLI</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ============================================</span>
<span class="c"># GESTIÓN DE POLICY DEFINITIONS</span>
<span class="c"># ============================================</span>

<span class="c"># Listar custom policies en un Management Group</span>
az policy definition list <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-root"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[?policyType=='Custom'].{Name:displayName, Mode:mode, Category:metadata.category}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># Ver detalles de una policy definition</span>
az policy definition show <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"pol-sec-deny-publicip-v1"</span> <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-root"</span>

<span class="c"># Crear custom policy desde archivo JSON</span>
az policy definition create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"pol-sec-deny-publicip-v1"</span> <span class="se">\</span>
  <span class="nt">--display-name</span> <span class="s2">"[Contoso] Deny Public IPs"</span> <span class="se">\</span>
  <span class="nt">--description</span> <span class="s2">"Denies creation of Public IP resources"</span> <span class="se">\</span>
  <span class="nt">--rules</span> <span class="s2">"policy-rules.json"</span> <span class="se">\</span>
  <span class="nt">--params</span> <span class="s2">"policy-params.json"</span> <span class="se">\</span>
  <span class="nt">--mode</span> <span class="s2">"All"</span> <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-root"</span>

<span class="c"># Listar built-in policies por categoría</span>
az policy definition list <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[?policyType=='BuiltIn' &amp;&amp; metadata.category=='Security'].{Name:displayName, ID:name}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># ============================================</span>
<span class="c"># GESTIÓN DE INITIATIVES</span>
<span class="c"># ============================================</span>

<span class="c"># Listar todas las initiatives</span>
az policy set-definition list <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-root"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[?policyType=='Custom'].{Name:displayName, Policies:length(policyDefinitions)}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># Ver políticas en una initiative</span>
az policy set-definition show <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"ini-security-baseline"</span> <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-root"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"policyDefinitions[].{Policy:policyDefinitionId, Effect:parameters.effect.defaultValue}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># ============================================</span>
<span class="c"># GESTIÓN DE ASSIGNMENTS</span>
<span class="c"># ============================================</span>

<span class="c"># Listar todos los assignments en un MG (heredados inclusive)</span>
az policy assignment list <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-corp-prod"</span> <span class="se">\</span>
  <span class="nt">--disable-scope-strict-match</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[].{Name:displayName, Enforce:enforcementMode, Scope:scope}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># Crear assignment con Managed Identity (para DINE)</span>
az policy assignment create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"pa-diagnostics-baseline"</span> <span class="se">\</span>
  <span class="nt">--display-name</span> <span class="s2">"Deploy Diagnostic Settings"</span> <span class="se">\</span>
  <span class="nt">--policy-set-definition</span> <span class="s2">"ini-monitoring-baseline"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span> <span class="se">\</span>
  <span class="nt">--mi-system-assigned</span> <span class="se">\</span>
  <span class="nt">--location</span> <span class="s2">"westeurope"</span> <span class="se">\</span>
  <span class="nt">--identity-scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span> <span class="se">\</span>
  <span class="nt">--role</span> <span class="s2">"Log Analytics Contributor"</span>

<span class="c"># Deshabilitar enforcement (modo DoNotEnforce / Audit-only)</span>
az policy assignment update <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"pa-diagnostics-baseline"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span> <span class="se">\</span>
  <span class="nt">--enforcement-mode</span> DoNotEnforce

<span class="c"># ============================================</span>
<span class="c"># COMPLIANCE Y REMEDIATION</span>
<span class="c"># ============================================</span>

<span class="c"># Forzar evaluación de compliance (trigger scan)</span>
az policy state trigger-scan <span class="se">\</span>
  <span class="nt">--resource-group</span> <span class="s2">"rg-app-prod"</span>

<span class="c"># Ver resumen de compliance por Management Group</span>
az policy state summarize <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-corp-prod"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"{Compliant:results.resourceDetails[?complianceState=='Compliant'].count, NonCompliant:results.resourceDetails[?complianceState=='NonCompliant'].count}"</span>

<span class="c"># Listar recursos no conformes con detalle</span>
az policy state list <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-corp-prod"</span> <span class="se">\</span>
  <span class="nt">--filter</span> <span class="s2">"complianceState eq 'NonCompliant'"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[].{Resource:resourceId, Policy:policyDefinitionName, Effect:additionalProperties.policyDefinitionEffect}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># Crear Remediation Task</span>
az policy remediation create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"remediate-diagnostics-</span><span class="si">$(</span><span class="nb">date</span> +%Y%m%d<span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-corp-prod"</span> <span class="se">\</span>
  <span class="nt">--policy-assignment</span> <span class="s2">"pa-diagnostics-baseline"</span> <span class="se">\</span>
  <span class="nt">--resource-discovery-mode</span> ReEvaluateCompliance

<span class="c"># Ver estado de remediation tasks</span>
az policy remediation list <span class="se">\</span>
  <span class="nt">--management-group</span> <span class="s2">"mg-corp-prod"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[].{Name:name, Status:properties.provisioningState, Succeeded:properties.deploymentStatus.successfulDeployments, Failed:properties.deploymentStatus.failedDeployments}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># ============================================</span>
<span class="c"># EXEMPTIONS</span>
<span class="c"># ============================================</span>

<span class="c"># Crear exemption con expiración obligatoria</span>
az policy exemption create <span class="se">\</span>
  <span class="nt">--name</span> <span class="s2">"exempt-publicip-sandbox-2026"</span> <span class="se">\</span>
  <span class="nt">--display-name</span> <span class="s2">"Temporary: Allow Public IPs in Sandbox"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-sandbox"</span> <span class="se">\</span>
  <span class="nt">--policy-assignment</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root/providers/Microsoft.Authorization/policyAssignments/pa-deny-publicip-root"</span> <span class="se">\</span>
  <span class="nt">--exemption-category</span> Waiver <span class="se">\</span>
  <span class="nt">--expires-on</span> <span class="s2">"2026-06-30T00:00:00Z"</span> <span class="se">\</span>
  <span class="nt">--description</span> <span class="s2">"Approved by: Governance Team | Ticket: GOV-2026-042 | Justification: Dev testing requires public access"</span>

<span class="c"># Listar exemptions activas</span>
az policy exemption list <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[].{Name:displayName, Category:properties.exemptionCategory, Expires:properties.expiresOn}"</span> <span class="se">\</span>
  <span class="nt">-o</span> table

<span class="c"># ============================================</span>
<span class="c"># EPAC WORKFLOW</span>
<span class="c"># ============================================</span>

<span class="c"># Instalar EPAC</span>
Install-Module EnterprisePolicyAsCode <span class="nt">-Force</span>

<span class="c"># Build deployment plans</span>
Build-DeploymentPlans <span class="se">\</span>
  <span class="nt">-DefinitionsRootFolder</span> ./Definitions <span class="se">\</span>
  <span class="nt">-OutputFolder</span> ./Output

<span class="c"># Deploy definitions</span>
Deploy-PolicyPlan <span class="se">\</span>
  <span class="nt">-DefinitionsRootFolder</span> ./Definitions <span class="se">\</span>
  <span class="nt">-PlanFile</span> ./Output/policy-plan.json

<span class="c"># Export current state to source control</span>
Export-AzPolicyResources <span class="se">\</span>
  <span class="nt">-DefinitionsRootFolder</span> ./ExportedDefinitions <span class="se">\</span>
  <span class="nt">-Mode</span> Export
</code></pre></div></div>

<h3 id="powershell">PowerShell</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ============================================</span><span class="w">
</span><span class="c"># VALIDACIÓN DE ESTRUCTURA</span><span class="w">
</span><span class="c"># ============================================</span><span class="w">

</span><span class="n">Connect-AzAccount</span><span class="w">

</span><span class="c"># Listar todas las custom policies de un tenant</span><span class="w">
</span><span class="n">Get-AzPolicyDefinition</span><span class="w"> </span><span class="nt">-Custom</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-Property</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"Name"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="p">}},</span><span class="w">
    </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"Category"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">Metadata</span><span class="o">.</span><span class="nf">Category</span><span class="p">}},</span><span class="w">
    </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"Mode"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">Mode</span><span class="p">}},</span><span class="w">
    </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"Effect"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">PolicyRule</span><span class="o">.</span><span class="nf">then</span><span class="o">.</span><span class="nf">effect</span><span class="p">}}</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Format-Table</span><span class="w"> </span><span class="nt">-AutoSize</span><span class="w">

</span><span class="c"># Ver compliance general del tenant</span><span class="w">
</span><span class="n">Get-AzPolicyStateSummary</span><span class="w"> </span><span class="nt">-ManagementGroupName</span><span class="w"> </span><span class="p">(</span><span class="n">Get-AzContext</span><span class="p">)</span><span class="o">.</span><span class="nf">Tenant</span><span class="o">.</span><span class="nf">Id</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-ExpandProperty</span><span class="w"> </span><span class="nx">Results</span><span class="w">

</span><span class="c"># Exportar todas las non-compliant resources a CSV</span><span class="w">
</span><span class="n">Get-AzPolicyState</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-ManagementGroupName</span><span class="w"> </span><span class="s2">"mg-corp-prod"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Filter</span><span class="w"> </span><span class="s2">"complianceState eq 'NonCompliant'"</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">ResourceId</span><span class="p">,</span><span class="w"> </span><span class="nx">PolicyDefinitionName</span><span class="p">,</span><span class="w"> </span><span class="nx">PolicyAssignmentName</span><span class="p">,</span><span class="w"> </span><span class="nx">ComplianceState</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Export-Csv</span><span class="w"> </span><span class="s2">"./reports/noncompliant-</span><span class="si">$(</span><span class="n">Get-Date</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="s1">'yyyyMMdd'</span><span class="p">)</span><span class="s2">.csv"</span><span class="w"> </span><span class="nt">-NoTypeInformation</span><span class="w">

</span><span class="c"># ============================================</span><span class="w">
</span><span class="c"># GESTIÓN DE ASSIGNMENTS</span><span class="w">
</span><span class="c"># ============================================</span><span class="w">

</span><span class="c"># Obtener todos los assignments con managed identity</span><span class="w">
</span><span class="n">Get-AzPolicyAssignment</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-IncludeDescendent</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Identity</span><span class="o">.</span><span class="nf">Type</span><span class="w"> </span><span class="o">-ne</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"IdentityType"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Identity</span><span class="o">.</span><span class="nf">Type</span><span class="p">}},</span><span class="w">
    </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"PrincipalId"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Identity</span><span class="o">.</span><span class="nf">PrincipalId</span><span class="p">}}</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Format-Table</span><span class="w">

</span><span class="c"># Verificar role assignments de managed identities de policies</span><span class="w">
</span><span class="nv">$policyAssignments</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyAssignment</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/mg-root"</span><span class="w"> </span><span class="se">`
</span><span class="w">  </span><span class="nt">-IncludeDescendent</span><span class="w"> </span><span class="o">|</span><span class="w">
  </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Identity</span><span class="o">.</span><span class="nf">Type</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"SystemAssigned"</span><span class="w"> </span><span class="p">}</span><span class="w">

</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$pa</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$policyAssignments</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nv">$roles</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzRoleAssignment</span><span class="w"> </span><span class="nt">-ObjectId</span><span class="w"> </span><span class="nv">$pa</span><span class="o">.</span><span class="nf">Identity</span><span class="o">.</span><span class="nf">PrincipalId</span><span class="w">
  </span><span class="nx">if</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="nv">$roles</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Write-Warning</span><span class="w"> </span><span class="s2">"Policy Assignment '</span><span class="si">$(</span><span class="nv">$pa</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="si">)</span><span class="s2">' has no role assignments!"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># ============================================</span><span class="w">
</span><span class="c"># REMEDIATION MONITORING</span><span class="w">
</span><span class="c"># ============================================</span><span class="w">

</span><span class="kr">function</span><span class="w"> </span><span class="nf">Get-PolicyRemediationStatus</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="kr">param</span><span class="p">([</span><span class="n">string</span><span class="p">]</span><span class="nv">$ManagementGroupId</span><span class="p">)</span><span class="w">

  </span><span class="nv">$remediations</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyRemediation</span><span class="w"> </span><span class="nt">-ManagementGroupId</span><span class="w"> </span><span class="nv">$ManagementGroupId</span><span class="w">

  </span><span class="nv">$summary</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$remediations</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Group-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ProvisioningState</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="nx">Count</span><span class="w">

  </span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"</span><span class="se">`n</span><span class="s2">=== Remediation Summary for </span><span class="nv">$ManagementGroupId</span><span class="s2"> ==="</span><span class="w"> </span><span class="nt">-ForegroundColor</span><span class="w"> </span><span class="nx">Cyan</span><span class="w">
  </span><span class="nv">$summary</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-Table</span><span class="w">

  </span><span class="nv">$failed</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$remediations</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ProvisioningState</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"Failed"</span><span class="w"> </span><span class="p">}</span><span class="w">
  </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$failed</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Write-Warning</span><span class="w"> </span><span class="s2">"Failed remediations detected:"</span><span class="w">
    </span><span class="nv">$failed</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nx">Name</span><span class="p">,</span><span class="w"> </span><span class="nx">PolicyAssignmentId</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Format-Table</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="n">Get-PolicyRemediationStatus</span><span class="w"> </span><span class="nt">-ManagementGroupId</span><span class="w"> </span><span class="s2">"mg-corp-prod"</span><span class="w">

</span><span class="c"># ============================================</span><span class="w">
</span><span class="c"># COMPLIANCE REPORT GENERATOR</span><span class="w">
</span><span class="c"># ============================================</span><span class="w">

</span><span class="kr">function</span><span class="w"> </span><span class="nf">New-PolicyComplianceReport</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="kr">param</span><span class="p">(</span><span class="w">
    </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$ManagementGroupId</span><span class="p">,</span><span class="w">
    </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$OutputPath</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"./reports"</span><span class="w">
  </span><span class="p">)</span><span class="w">

  </span><span class="nv">$date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Date</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="s1">'yyyy-MM-dd'</span><span class="w">
  </span><span class="nv">$states</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyState</span><span class="w"> </span><span class="nt">-ManagementGroupName</span><span class="w"> </span><span class="nv">$ManagementGroupId</span><span class="w">
  </span><span class="nv">$summary</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$states</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Group-Object</span><span class="w"> </span><span class="nx">ComplianceState</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"State"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Name</span><span class="p">}},</span><span class="w"> </span><span class="nx">Count</span><span class="w">

  </span><span class="nv">$byPolicy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$states</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ComplianceState</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"NonCompliant"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Group-Object</span><span class="w"> </span><span class="nx">PolicyDefinitionName</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="p">@{</span><span class="nx">N</span><span class="o">=</span><span class="s2">"Policy"</span><span class="p">;</span><span class="w"> </span><span class="nx">E</span><span class="o">=</span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Name</span><span class="p">}},</span><span class="w"> </span><span class="nx">Count</span><span class="w"> </span><span class="o">|</span><span class="w">
    </span><span class="n">Sort-Object</span><span class="w"> </span><span class="nx">Count</span><span class="w"> </span><span class="nt">-Descending</span><span class="w">

  </span><span class="nv">$html</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sh">@"
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;&lt;title&gt;Policy Compliance Report - </span><span class="nv">$date</span><span class="sh">&lt;/title&gt;&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Azure Policy Compliance Report&lt;/h1&gt;
&lt;h2&gt;Management Group: </span><span class="nv">$ManagementGroupId</span><span class="sh">&lt;/h2&gt;
&lt;h3&gt;Date: </span><span class="nv">$date</span><span class="sh">&lt;/h3&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
</span><span class="si">$(</span><span class="p">(</span><span class="nv">$summary</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Html</span><span class="w"> </span><span class="nt">-Fragment</span><span class="p">)</span><span class="sh">)
&lt;h2&gt;Top Non-Compliant Policies&lt;/h2&gt;
</span><span class="si">$(</span><span class="p">(</span><span class="nv">$byPolicy</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">20</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertTo-Html</span><span class="w"> </span><span class="nt">-Fragment</span><span class="p">)</span><span class="sh">)
&lt;/body&gt;
&lt;/html&gt;
"@</span><span class="w">

  </span><span class="n">New-Item</span><span class="w"> </span><span class="nt">-ItemType</span><span class="w"> </span><span class="nx">Directory</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$OutputPath</span><span class="w"> </span><span class="nt">-Force</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
  </span><span class="nv">$html</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-File</span><span class="w"> </span><span class="s2">"</span><span class="nv">$OutputPath</span><span class="s2">/compliance-report-</span><span class="nv">$date</span><span class="s2">.html"</span><span class="w">
  </span><span class="nv">$states</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Export-Csv</span><span class="w"> </span><span class="s2">"</span><span class="nv">$OutputPath</span><span class="s2">/compliance-raw-</span><span class="nv">$date</span><span class="s2">.csv"</span><span class="w"> </span><span class="nt">-NoTypeInformation</span><span class="w">
  </span><span class="n">Write-Host</span><span class="w"> </span><span class="s2">"Report saved to </span><span class="nv">$OutputPath</span><span class="s2">"</span><span class="w"> </span><span class="nt">-ForegroundColor</span><span class="w"> </span><span class="nx">Green</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="n">New-PolicyComplianceReport</span><span class="w"> </span><span class="nt">-ManagementGroupId</span><span class="w"> </span><span class="s2">"mg-root"</span><span class="w">
</span></code></pre></div></div>

<h3 id="tests-automatizados-pester--psrule">Tests Automatizados (Pester + PSRule)</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># tests/AzurePolicy.Tests.ps1</span><span class="w">
</span><span class="n">Describe</span><span class="w"> </span><span class="s2">"Azure Policy Governance Tests"</span><span class="w"> </span><span class="p">{</span><span class="w">

  </span><span class="n">BeforeAll</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Connect-AzAccount</span><span class="w"> </span><span class="nt">-Identity</span><span class="w">
    </span><span class="nv">$tenantId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-AzContext</span><span class="p">)</span><span class="o">.</span><span class="nf">Tenant</span><span class="o">.</span><span class="nf">Id</span><span class="w">
  </span><span class="p">}</span><span class="w">

  </span><span class="n">Context</span><span class="w"> </span><span class="s2">"Policy Definition Quality"</span><span class="w"> </span><span class="p">{</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"All custom policies should have a version in metadata"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$policies</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyDefinition</span><span class="w"> </span><span class="nt">-Custom</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$policy</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$policies</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$policy</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">Metadata</span><span class="o">.</span><span class="nf">Version</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w"> </span><span class="se">`
</span><span class="w">          </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"</span><span class="si">$(</span><span class="nv">$policy</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="si">)</span><span class="s2"> must be versioned"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"All custom policies should have a category"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$policies</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyDefinition</span><span class="w"> </span><span class="nt">-Custom</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$policy</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$policies</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$policy</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">Metadata</span><span class="o">.</span><span class="nf">Category</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"DINE policies should use parameterized effect"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$dinePolicies</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyDefinition</span><span class="w"> </span><span class="nt">-Custom</span><span class="w"> </span><span class="o">|</span><span class="w">
        </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">PolicyRule</span><span class="o">.</span><span class="nf">then</span><span class="o">.</span><span class="nf">effect</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"*DeployIfNotExists*"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$policy</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$dinePolicies</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$effect</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$policy</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">PolicyRule</span><span class="o">.</span><span class="nf">then</span><span class="o">.</span><span class="nf">effect</span><span class="w">
        </span><span class="nv">$effect</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Match</span><span class="w"> </span><span class="s2">"\[parameters\("</span><span class="w"> </span><span class="se">`
</span><span class="w">          </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"Effects should be parameterized, not hardcoded"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">

  </span><span class="n">Context</span><span class="w"> </span><span class="s2">"Assignment Coverage"</span><span class="w"> </span><span class="p">{</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Root MG should have Require-Tags policy"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$assignment</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyAssignment</span><span class="w"> </span><span class="se">`
</span><span class="w">        </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/</span><span class="nv">$tenantId</span><span class="s2">"</span><span class="w"> </span><span class="o">|</span><span class="w">
        </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"tag"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="nv">$assignment</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"All Production MGs should have backup policy assigned"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$prodMGs</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzManagementGroup</span><span class="w"> </span><span class="o">|</span><span class="w">
        </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">DisplayName</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"*Production*"</span><span class="w"> </span><span class="o">-or</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">DisplayName</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"*Prod*"</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$mg</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$prodMGs</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$backupPolicy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyAssignment</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="nv">$mg</span><span class="o">.</span><span class="nf">Id</span><span class="w"> </span><span class="o">|</span><span class="w">
          </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"backup"</span><span class="w"> </span><span class="p">}</span><span class="w">
        </span><span class="nv">$backupPolicy</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w"> </span><span class="se">`
</span><span class="w">          </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"</span><span class="si">$(</span><span class="nv">$mg</span><span class="o">.</span><span class="nf">DisplayName</span><span class="si">)</span><span class="s2"> must enforce backup"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"DINE assignments should have managed identity"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$allAssignments</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyAssignment</span><span class="w"> </span><span class="se">`
</span><span class="w">        </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/</span><span class="nv">$tenantId</span><span class="s2">"</span><span class="w"> </span><span class="se">`
</span><span class="w">        </span><span class="nt">-IncludeDescendent</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$pa</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$allAssignments</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$policyDef</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyDefinition</span><span class="w"> </span><span class="nt">-Id</span><span class="w"> </span><span class="nv">$pa</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">PolicyDefinitionId</span><span class="w"> </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">SilentlyContinue</span><span class="w">
        </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$policyDef</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">PolicyRule</span><span class="o">.</span><span class="nf">then</span><span class="o">.</span><span class="nf">effect</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"*DeployIfNotExists*"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
          </span><span class="nv">$pa</span><span class="o">.</span><span class="nf">Identity</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w"> </span><span class="se">`
</span><span class="w">            </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"</span><span class="si">$(</span><span class="nv">$pa</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="si">)</span><span class="s2"> is DINE and needs managed identity"</span><span class="w">
        </span><span class="p">}</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">

  </span><span class="n">Context</span><span class="w"> </span><span class="s2">"Exemption Governance"</span><span class="w"> </span><span class="p">{</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"All exemptions should have expiration date"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$exemptions</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyExemption</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/</span><span class="nv">$tenantId</span><span class="s2">"</span><span class="w">
      </span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$ex</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$exemptions</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="nv">$ex</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">ExpiresOn</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="o">-Not</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w"> </span><span class="se">`
</span><span class="w">          </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"Exemption '</span><span class="si">$(</span><span class="nv">$ex</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">DisplayName</span><span class="si">)</span><span class="s2">' must have expiry date"</span><span class="w">
      </span><span class="p">}</span><span class="w">
    </span><span class="p">}</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"No exemptions should be expired"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$exemptions</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyExemption</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="s2">"/providers/Microsoft.Management/managementGroups/</span><span class="nv">$tenantId</span><span class="s2">"</span><span class="w">
      </span><span class="nv">$expired</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$exemptions</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
        </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">ExpiresOn</span><span class="w"> </span><span class="o">-ne</span><span class="w"> </span><span class="bp">$null</span><span class="w"> </span><span class="o">-and</span><span class="w">
        </span><span class="p">[</span><span class="n">DateTime</span><span class="p">]</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Properties</span><span class="o">.</span><span class="nf">ExpiresOn</span><span class="w"> </span><span class="o">-lt</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Date</span><span class="p">)</span><span class="w">
      </span><span class="p">}</span><span class="w">
      </span><span class="nv">$expired</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeNullOrEmpty</span><span class="w"> </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"Expired exemptions must be removed"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">

  </span><span class="n">Context</span><span class="w"> </span><span class="s2">"Compliance Thresholds"</span><span class="w"> </span><span class="p">{</span><span class="w">

    </span><span class="n">It</span><span class="w"> </span><span class="s2">"Production MGs compliance rate should exceed 90%"</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nv">$states</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzPolicyState</span><span class="w"> </span><span class="nt">-ManagementGroupName</span><span class="w"> </span><span class="s2">"mg-corp-prod"</span><span class="w">
      </span><span class="nv">$total</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$states</span><span class="o">.</span><span class="nf">Count</span><span class="w">
      </span><span class="nv">$compliant</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$states</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ComplianceState</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"Compliant"</span><span class="w"> </span><span class="p">})</span><span class="o">.</span><span class="nf">Count</span><span class="w">
      </span><span class="nv">$rate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$total</span><span class="w"> </span><span class="o">-gt</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="p">(</span><span class="nv">$compliant</span><span class="w"> </span><span class="n">/</span><span class="w"> </span><span class="nv">$total</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="nx">100</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="mi">100</span><span class="w"> </span><span class="p">}</span><span class="w">
      </span><span class="nv">$rate</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Should</span><span class="w"> </span><span class="nt">-BeGreaterThan</span><span class="w"> </span><span class="nx">90</span><span class="w"> </span><span class="nt">-Because</span><span class="w"> </span><span class="s2">"Production compliance must exceed 90%"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="n">Invoke-Pester</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">"./tests/AzurePolicy.Tests.ps1"</span><span class="w"> </span><span class="nt">-Output</span><span class="w"> </span><span class="nx">Detailed</span><span class="w">
</span></code></pre></div></div>

<h2 id="-9-decisiones-adr">📝 9. Decisiones (ADR)</h2>

<h3 id="adr-001-epac-como-framework-principal-de-policy-as-code">ADR-001: EPAC como framework principal de Policy as Code</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-09-01</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Necesitábamos gestionar 200+ policy assignments a través de 15 Management Groups de forma reproducible y auditada</li>
  <li><strong>Decisión</strong>: Adoptar Enterprise Policy as Code (EPAC) como framework oficial de Policy as Code</li>
  <li><strong>Alternativas consideradas</strong>:
    <ol>
      <li>Terraform puro (azurerm provider)</li>
      <li>Bicep + ARM Templates</li>
      <li>Azure CLI scripts en pipelines</li>
      <li>Manual via portal</li>
    </ol>
  </li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ Framework diseñado específicamente para Azure Policy a escala</li>
      <li>✅ Soporte oficial de Microsoft</li>
      <li>✅ Integración nativa con AMBA (Azure Monitor Baseline Alerts)</li>
      <li>✅ Drift detection incorporado</li>
      <li>✅ Export de estado actual a código</li>
      <li>⚠️ Curva de aprendizaje en JSONC y estructura EPAC</li>
      <li>⚠️ Dependencia de módulo PowerShell (no HCL)</li>
      <li>⚠️ No gestiona otros recursos Azure (solo policy)</li>
    </ul>
  </li>
</ul>

<h3 id="adr-002-efecto-parametrizado-en-todas-las-custom-policies">ADR-002: Efecto parametrizado en todas las custom policies</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-09-15</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Necesitábamos poder cambiar el efecto (Deny→Audit) sin modificar la definición</li>
  <li><strong>Decisión</strong>: Toda custom policy debe exponer el efecto como parámetro con <code class="language-plaintext highlighter-rouge">defaultValue</code> apropiado por entorno</li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ Una definición → múltiples comportamientos según entorno</li>
      <li>✅ Rollout gradual: empezar en Audit, promover a Deny</li>
      <li>✅ Testing seguro en Sandbox sin riesgo de bloqueos</li>
      <li>⚠️ Assignments más verbosos con parámetros</li>
    </ul>
  </li>
</ul>

<h3 id="adr-003-versionado-explícito-de-built-in-policies-en-assignments">ADR-003: Versionado explícito de built-in policies en assignments</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-11-01</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Microsoft actualiza built-in policies automáticamente, lo que puede romper comportamientos esperados</li>
  <li><strong>Decisión</strong>: Usar versiones fijas en Policy Assignments en producción (feature en Public Preview 2024)</li>
  <li><strong>Ref</strong>: Public Preview Built-in Policy Versioning: <code class="language-plaintext highlighter-rouge">https://techcommunity.microsoft.com/t5/azure-governance-and-management/public-preview-announcement-azure-policy-built-in-versioning/ba-p/4186105</code></li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ Cambios en built-ins no afectan producción sin revisión</li>
      <li>✅ Pipeline de actualización controlada de versiones</li>
      <li>⚠️ Requiere proceso activo de actualización de versiones</li>
    </ul>
  </li>
</ul>

<h3 id="adr-004-exemptions-siempre-con-expiración-y-ticket">ADR-004: Exemptions siempre con expiración y ticket</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-10-01</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Las exemptions sin control crean deuda de gobernanza</li>
  <li><strong>Decisión</strong>: Toda exemption DEBE tener: <code class="language-plaintext highlighter-rouge">expires_on</code> (máximo 90 días), <code class="language-plaintext highlighter-rouge">approvedBy</code>, <code class="language-plaintext highlighter-rouge">ticketNumber</code> en metadata</li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ No hay exemptions eternas</li>
      <li>✅ Trazabilidad completa de excepciones</li>
      <li>✅ Revisión trimestral obligatoria</li>
      <li>⚠️ Proceso formal de aprobación requerido</li>
    </ul>
  </li>
</ul>

<h3 id="adr-005-initiative-de-monitoring-aplicada-en-root-mg">ADR-005: Initiative de Monitoring aplicada en Root MG</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-10-20</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Necesitábamos garantizar que TODOS los recursos envíen telemetría a Log Analytics</li>
  <li><strong>Decisión</strong>: DINE Diagnostic Settings initiative asignada en Root MG, en modo <code class="language-plaintext highlighter-rouge">DoNotEnforce</code> inicialmente, promovida a <code class="language-plaintext highlighter-rouge">Enforce</code> tras 60 días</li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ Cobertura de monitoring al 100% del tenant</li>
      <li>✅ Remediación automática de recursos existentes</li>
      <li>⚠️ Managed Identity con permisos amplios sobre el tenant</li>
    </ul>
  </li>
</ul>

<h3 id="adr-006-amba-para-alertas-de-azure-monitor-vía-policy">ADR-006: AMBA para alertas de Azure Monitor vía Policy</h3>

<ul>
  <li><strong>Fecha</strong>: 2025-12-01</li>
  <li><strong>Estado</strong>: ✅ Aceptado</li>
  <li><strong>Contexto</strong>: Necesitábamos alertas de monitoreo consistentes sin configuración manual</li>
  <li><strong>Decisión</strong>: Adoptar Azure Monitor Baseline Alerts (AMBA) deployado via EPAC</li>
  <li><strong>Ref</strong>: <code class="language-plaintext highlighter-rouge">https://www.azuredoctor.com/posts/automate-azure-alerts-with-amba/</code></li>
  <li><strong>Consecuencias</strong>:
    <ul>
      <li>✅ Alertas baseline para 40+ tipos de recursos automáticamente</li>
      <li>✅ Mantenido por Microsoft/comunidad</li>
      <li>✅ Integración nativa con EPAC</li>
      <li>⚠️ Requiere Action Groups configurados previamente</li>
    </ul>
  </li>
</ul>

<h2 id="-10-referencias">🔗 10. Referencias</h2>

<table>
  <thead>
    <tr>
      <th>Descripción</th>
      <th>URL</th>
      <th>Notas</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Documentación oficial Azure Policy</strong></td>
      <td>https://learn.microsoft.com/es-es/azure/governance/policy/</td>
      <td>Microsoft Docs ES</td>
    </tr>
    <tr>
      <td><strong>ALZ Policies - La Biblia</strong></td>
      <td>https://github.com/Azure/Enterprise-Scale/wiki/ALZ-Policies</td>
      <td>Políticas oficiales de Landing Zones</td>
    </tr>
    <tr>
      <td><strong>ALZ Policy Refresh Q1 FY25</strong></td>
      <td>https://github.com/Azure/Enterprise-Scale/wiki/Whats-new#-policy-refresh-q1-fy25</td>
      <td>Últimas actualizaciones de políticas ALZ</td>
    </tr>
    <tr>
      <td><strong>EPAC - Enterprise Policy as Code</strong></td>
      <td>https://azure.github.io/enterprise-azure-policy-as-code/</td>
      <td>Framework oficial de PaC</td>
    </tr>
    <tr>
      <td><strong>EPAC Revisiting v10</strong></td>
      <td>https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/revisiting-enterprise-policy-as-code-v10/ba-p/4128448</td>
      <td>Actualización mayor de EPAC</td>
    </tr>
    <tr>
      <td><strong>azure-policy (repositorio oficial)</strong></td>
      <td>https://github.com/Azure/azure-policy</td>
      <td>Código fuente built-in policies</td>
    </tr>
    <tr>
      <td><strong>Community Policy repo</strong></td>
      <td>https://github.com/Azure/Community-Policy/tree/main/Policies</td>
      <td>Políticas contribuidas por comunidad</td>
    </tr>
    <tr>
      <td><strong>Custom Policies con Terraform</strong></td>
      <td>https://www.lindbergtech.com/write-your-own-custom-azure-policies-with-terraform/</td>
      <td>Guía práctica Terraform + Policy</td>
    </tr>
    <tr>
      <td><strong>Custom Policy Builder</strong></td>
      <td>https://github.com/carlzxc71/azure-policy-custompolicy</td>
      <td>Herramienta para crear custom policies</td>
    </tr>
    <tr>
      <td><strong>AzAdvertizer</strong></td>
      <td>https://www.azadvertizer.net/</td>
      <td>Explorador de policies, initiatives, roles</td>
    </tr>
    <tr>
      <td><strong>Awesome Azure Policy</strong></td>
      <td>https://github.com/globalbao/awesome-azure-policy</td>
      <td>Colección curada de recursos</td>
    </tr>
    <tr>
      <td><strong>ENS Spain</strong></td>
      <td>https://learn.microsoft.com/es-es/azure/governance/policy/samples/spain-ens</td>
      <td>Initiative ENS para administraciones españolas</td>
    </tr>
    <tr>
      <td><strong>Policy Anatomy Part 1</strong></td>
      <td>https://laythchebbi.com/index.php/2024/05/26/azure-policy-anatomy/</td>
      <td>Anatomía detallada de policies</td>
    </tr>
    <tr>
      <td><strong>Policy Anatomy Part 2</strong></td>
      <td>https://laythchebbi.com/index.php/2024/06/09/azure-policy-anatomy-part-2-building-custom-policies-and-azure-governance/</td>
      <td>Custom policies avanzadas</td>
    </tr>
    <tr>
      <td><strong>Built-in Policy Versioning (Preview)</strong></td>
      <td>https://techcommunity.microsoft.com/t5/azure-governance-and-management/public-preview-announcement-azure-policy-built-in-versioning/ba-p/4186105</td>
      <td>Versionado de built-in policies</td>
    </tr>
    <tr>
      <td><strong>NSG Association via Policy</strong></td>
      <td>https://techcommunity.microsoft.com/blog/azurenetworkingblog/manage-nsg-association-on-subnets-via-azure-policy/4243157</td>
      <td>Gestionar NSG en subnets via policy</td>
    </tr>
    <tr>
      <td><strong>IaC Testing with Policy</strong></td>
      <td>https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/infrastructure-as-code-testing-with-azure-policy/ba-p/3921765</td>
      <td>Tests de IaC con Policy + PSRule</td>
    </tr>
    <tr>
      <td><strong>Policy Recommended Practices</strong></td>
      <td>https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/azure-policy-recommended-practices/ba-p/3798024</td>
      <td>Best practices oficiales</td>
    </tr>
    <tr>
      <td><strong>Cost Governance with Policy</strong></td>
      <td>https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/cost-governance-with-azure-policy/ba-p/3791888</td>
      <td>Gobernanza de costes</td>
    </tr>
    <tr>
      <td><strong>DenyActions &amp; Resource Locks</strong></td>
      <td>https://journeyofthegeek.com/2024/05/08/azure-authorization-resource-locks-and-azure-policy-denyactions/</td>
      <td>DenyAction effect explicado</td>
    </tr>
    <tr>
      <td><strong>Policy Modify Effect</strong></td>
      <td>https://www.georgeollis.com/azure-policy-modify/</td>
      <td>Guía del efecto Modify</td>
    </tr>
    <tr>
      <td><strong>AMBA - Azure Monitor Baseline Alerts</strong></td>
      <td>https://github.com/Azure/alz-monitor/wiki/Introduction-to-deploying-ALZ-Monitor</td>
      <td>Alertas baseline vía Policy</td>
    </tr>
    <tr>
      <td><strong>Deploy AMBA with EPAC</strong></td>
      <td>https://www.markou.me/2024/04/deploy-azure-monitor-baseline-alerts-with-enterprise-policy-as-code/</td>
      <td>Integración AMBA + EPAC</td>
    </tr>
    <tr>
      <td><strong>AzurePolicyAgents</strong></td>
      <td>https://github.com/Azure/AzurePolicyAgents/tree/main</td>
      <td>Agentes de Policy</td>
    </tr>
    <tr>
      <td><strong>Policy Assessment Tool</strong></td>
      <td>https://github.com/ripom/AzurePolicyAssessment</td>
      <td>Herramienta de evaluación</td>
    </tr>
    <tr>
      <td><strong>DNS Private Endpoint via Policy</strong></td>
      <td>https://blog.cloud63.fr/azure-private-endpoint-dns-integration-one-policy-to-rule-them-all/amp/</td>
      <td>DNS integration con Policy</td>
    </tr>
    <tr>
      <td><strong>Backup VMs via Policy</strong></td>
      <td>https://charbelnemnom.com/enable-azure-backup-on-vms-using-azure-policy/</td>
      <td>Habilitar backup automático</td>
    </tr>
    <tr>
      <td><strong>Implementing Governance with Policy</strong></td>
      <td>https://techcommunity.microsoft.com/t5/core-infrastructure-and-security/implementing-governance-for-your-azure-cloud-using-azure-policy/ba-p/4251248</td>
      <td>Guía completa de implementación</td>
    </tr>
    <tr>
      <td><strong>EPAC Cloud Tips</strong></td>
      <td>https://cloudtips.nl/azure-enterprise-policy-as-code-epac-534047d31270</td>
      <td>Introducción práctica a EPAC</td>
    </tr>
    <tr>
      <td><strong>Training: Configure RBAC</strong></td>
      <td>https://learn.microsoft.com/es-es/training/modules/configure-role-based-access-control/8-simulation-role-based</td>
      <td>Lab interactivo RBAC</td>
    </tr>
    <tr>
      <td><strong>Training: Configure Azure Policy</strong></td>
      <td>https://learn.microsoft.com/es-es/training/modules/configure-azure-policy/9-simulation-policy</td>
      <td>Lab interactivo Policy</td>
    </tr>
    <tr>
      <td><strong>Training: ARM Templates</strong></td>
      <td>https://learn.microsoft.com/es-es/training/modules/configure-resources-arm-templates/7-simulation-template</td>
      <td>Lab ARM Templates</td>
    </tr>
    <tr>
      <td><strong>Training: Azure PowerShell</strong></td>
      <td>https://learn.microsoft.com/es-es/training/modules/configure-azure-resources-tools/5-simulation-powershell</td>
      <td>Lab PowerShell</td>
    </tr>
    <tr>
      <td><strong>Training: Azure CLI</strong></td>
      <td>https://learn.microsoft.com/es-es/training/modules/configure-azure-resources-tools/7-simulation-command-line</td>
      <td>Lab CLI</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="-recursos-adicionales">📚 Recursos Adicionales</h2>

<h3 id="scripts-útiles">Scripts Útiles</h3>

<ul>
  <li><strong>Export Policy State</strong>: <code class="language-plaintext highlighter-rouge">scripts/export-policy-state.ps1</code> → Exporta estado actual a EPAC</li>
  <li><strong>Compliance Report</strong>: <code class="language-plaintext highlighter-rouge">scripts/generate-compliance-report.ps1</code> → Reporte HTML semanal</li>
  <li><strong>Remediation Trigger</strong>: <code class="language-plaintext highlighter-rouge">scripts/trigger-remediation.ps1</code> → Lanza remediación masiva</li>
  <li><strong>Exemption Audit</strong>: <code class="language-plaintext highlighter-rouge">scripts/audit-exemptions.ps1</code> → Lista exemptions próximas a expirar</li>
  <li><strong>Policy Drift Check</strong>: <code class="language-plaintext highlighter-rouge">scripts/check-epac-drift.ps1</code> → Detecta drift vs source control</li>
</ul>

<h3 id="diagramas">Diagramas</h3>

<ul>
  <li><strong>Policy Anatomy</strong>: <code class="language-plaintext highlighter-rouge">_assets/diagrams/policy-anatomy.drawio</code></li>
  <li><strong>EPAC Pipeline</strong>: <code class="language-plaintext highlighter-rouge">_assets/diagrams/epac-cicd-flow.drawio</code></li>
  <li><strong>Assignment Matrix</strong>: <code class="language-plaintext highlighter-rouge">_assets/diagrams/policy-assignments-matrix.xlsx</code></li>
  <li><strong>Effect Decision Tree</strong>: <code class="language-plaintext highlighter-rouge">_assets/diagrams/policy-effect-decision-tree.drawio</code></li>
</ul>

<h3 id="runbooks">Runbooks</h3>

<ul>
  <li><strong>Nueva Custom Policy</strong>: <code class="language-plaintext highlighter-rouge">runbooks/create-custom-policy.md</code></li>
  <li><strong>Proceso de Exemption</strong>: <code class="language-plaintext highlighter-rouge">runbooks/policy-exemption-request.md</code></li>
  <li><strong>Rollout de Deny Policy</strong>: <code class="language-plaintext highlighter-rouge">runbooks/deny-policy-rollout.md</code> (Audit → Enforce en fases)</li>
  <li><strong>Remediation Masiva</strong>: <code class="language-plaintext highlighter-rouge">runbooks/mass-remediation.md</code></li>
  <li><strong>Actualización de Built-in</strong>: <code class="language-plaintext highlighter-rouge">runbooks/update-builtin-policy-version.md</code></li>
</ul>

<hr />

<p><strong>Última actualización</strong>: 2026-02-18
<strong>Próxima revisión</strong>: 2026-05-18
<strong>Owner</strong>: Luis Adán Muñoz (@LuisAdan)
<strong>Reviewers</strong>: Platform Team, Security Team, Compliance Team</p>

<hr />

<h2 id="-quick-start-commands">🎯 Quick Start Commands</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ver compliance actual del tenant</span>
az policy state summarize <span class="nt">--management-group</span> <span class="s2">"</span><span class="si">$(</span>az account show <span class="nt">--query</span> tenantId <span class="nt">-o</span> tsv<span class="si">)</span><span class="s2">"</span>

<span class="c"># Ver top 10 non-compliant policies</span>
az policy state list <span class="nt">--management-group</span> <span class="s2">"mg-root"</span> <span class="se">\</span>
  <span class="nt">--filter</span> <span class="s2">"complianceState eq 'NonCompliant'"</span> <span class="se">\</span>
  <span class="nt">--query</span> <span class="s2">"[].policyDefinitionName"</span> <span class="nt">-o</span> tsv | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-c</span> | <span class="nb">sort</span> <span class="nt">-rn</span> | <span class="nb">head</span> <span class="nt">-10</span>

<span class="c"># Desplegar con EPAC</span>
Build-DeploymentPlans <span class="nt">-DefinitionsRootFolder</span> ./Definitions <span class="nt">-OutputFolder</span> ./Output
Deploy-PolicyPlan <span class="nt">-DefinitionsRootFolder</span> ./Definitions <span class="nt">-PlanFile</span> ./Output/policy-plan.json

<span class="c"># Generar reporte de compliance</span>
pwsh ./scripts/generate-compliance-report.ps1 <span class="nt">-ManagementGroupId</span> <span class="s2">"mg-root"</span>

<span class="c"># Auditar exemptions próximas a expirar</span>
pwsh ./scripts/audit-exemptions.ps1 <span class="nt">-DaysToExpiry</span> 14
</code></pre></div></div>

<hr />

<p><strong>¿Preguntas?</strong> Contacta al equipo de Governance en Slack: <code class="language-plaintext highlighter-rouge">#governance-team</code> o abre un issue en el repositorio.</p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Resources" /><category term="Gobernanza" /><category term="azure" /><category term="policy" /><category term="governance" /><category term="compliance" /><category term="policy-as-code" /><category term="epac" /><category term="initiative" /><category term="remediation" /><summary type="html"><![CDATA[📋 1. Resumen]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">DocAzureAutomatic_v2: Auditoría de Seguridad con Prowler y Checkov</title><link href="https://luisadanmunoz.es/posts/DocAzureAutomatic_v2/" rel="alternate" type="text/html" title="DocAzureAutomatic_v2: Auditoría de Seguridad con Prowler y Checkov" /><published>2026-01-27T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/DocAzureAutomatic_v2</id><content type="html" xml:base="https://luisadanmunoz.es/posts/DocAzureAutomatic_v2/"><![CDATA[<h1 id="-docazureautomatic-v2-cuando-la-documentación-se-encuentra-con-devsecops">🔒 DocAzureAutomatic v2: Cuando la Documentación se Encuentra con DevSecOps</h1>

<h2 id="introducción-de-documentación-a-security-intelligence">Introducción: De Documentación a Security Intelligence</h2>

<p>En la primera versión de <strong>DocAzureAutomatic</strong>, revolucionamos cómo documentamos infraestructura Azure: exportación automática de ARM templates, generación de Markdown detallado y reportes HTML profesionales. Pero en el mundo cloud actual, documentar <strong>qué tienes</strong> ya no es suficiente. Necesitas saber si lo que tienes es <strong>seguro</strong>.</p>

<p><strong>DocAzureAutomatic v2</strong> da el siguiente paso evolutivo: combina documentación automática con análisis de seguridad de clase enterprise usando <strong>Prowler</strong> y <strong>Checkov</strong>, las herramientas preferidas por equipos de DevSecOps a nivel mundial.</p>

<h2 id="-qué-hay-de-nuevo-en-v2">🎯 ¿Qué hay de nuevo en v2?</h2>

<h3 id="1-unificación-total-docazureautomatic_allinoneps1">1. <strong>Unificación Total: DocAzureAutomatic_AllInOne.ps1</strong></h3>

<p>La arquitectura se simplifica drásticamente. Lo que antes requería dos scripts separados (PowerShell + Python) ahora es <strong>un solo archivo ejecutable</strong>:</p>

<pre><code class="language-mermaid">graph TB
    A[DocAzureAutomatic_AllInOne.ps1] --&gt; B[Azure Login &amp; Export]
    B --&gt; C[ARM Templates JSON]
    C --&gt; D[Markdown Generation]
    D --&gt; E[Python Embebido]
    E --&gt; F[HTML + CSS]
    
    style A fill:#0078D4,color:#fff
    style F fill:#00AA00,color:#fff
</code></pre>

<p><strong>Beneficios de la unificación:</strong></p>
<ul>
  <li>✅ Un solo comando para ejecutar todo el pipeline</li>
  <li>✅ Python embebido dentro del script PowerShell</li>
  <li>✅ Cero dependencias de archivos externos (excepto styles.css)</li>
  <li>✅ Deployment más simple en entornos corporativos</li>
  <li>✅ Menos superficie de error</li>
</ul>

<h3 id="2-análisis-de-seguridad-con-prowler--checkov">2. <strong>Análisis de Seguridad con Prowler + Checkov</strong></h3>

<p>La estrella de v2: <strong>make-report.v2.ps1</strong>, un orchestrator de seguridad que:</p>

<pre><code class="language-mermaid">graph LR
    A[ARM JSON Files] --&gt; B[Prowler Azure Runtime]
    A --&gt; C[Checkov Static Analysis]
    B --&gt; D[Prowler Reports]
    C --&gt; E[Checkov SARIF]
    D --&gt; F[Unified HTML Dashboard]
    E --&gt; F
    
    style B fill:#FF6B35,color:#fff
    style C fill:#4ECDC4,color:#fff
    style F fill:#95E1D3,color:#000
</code></pre>

<p><strong>Prowler</strong>: Análisis en tiempo real contra las suscripciones de Azure. Detecta misconfigurations, vulnerabilidades de compliance y desviaciones de best practices directamente en tu tenant.</p>

<p><strong>Checkov</strong>: Análisis estático de tus ARM templates exportados. Encuentra issues antes de desplegar, valida contra CIS Benchmarks, PCI-DSS, HIPAA y más.</p>

<h3 id="3-entorno-virtual-python-para-aislamiento">3. <strong>Entorno Virtual Python para Aislamiento</strong></h3>

<p>v2 introduce gestión profesional de dependencias con virtual environments:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Instalación aislada de herramientas</span><span class="w">
</span><span class="n">python</span><span class="w"> </span><span class="nt">-m</span><span class="w"> </span><span class="nx">venv</span><span class="w"> </span><span class="o">.</span><span class="nf">venv</span><span class="w">
</span><span class="o">.</span><span class="nx">\.venv\Scripts\Activate.ps1</span><span class="w">
</span><span class="n">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">prowler</span><span class="w"> </span><span class="nx">checkov</span><span class="w">
</span></code></pre></div></div>

<p><strong>Ventajas del venv:</strong></p>
<ul>
  <li>🔒 Aislamiento de dependencias (no contaminas el Python global)</li>
  <li>🔄 Versionado reproducible de herramientas</li>
  <li>🚀 Portabilidad: lleva tu .venv a cualquier máquina</li>
  <li>👥 Colaboración en equipo sin conflictos de versiones</li>
</ul>

<h2 id="️-arquitectura-completa-del-sistema-v2">🏗️ Arquitectura Completa del Sistema v2</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant User
    participant AllInOne as DocAzureAutomatic_AllInOne.ps1
    participant Azure as Azure ARM API
    participant MakeReport as make-report.v2.ps1
    participant Prowler
    participant Checkov
    
    User-&gt;&gt;AllInOne: Ejecutar pipeline
    AllInOne-&gt;&gt;Azure: az login + export ARM
    Azure--&gt;&gt;AllInOne: JSON templates
    AllInOne-&gt;&gt;AllInOne: Generate Markdown
    AllInOne-&gt;&gt;AllInOne: Python MD→HTML
    AllInOne--&gt;&gt;User: Documentación HTML
    
    User-&gt;&gt;MakeReport: Ejecutar análisis seguridad
    MakeReport-&gt;&gt;Azure: Crear SP temporal + RBAC
    MakeReport-&gt;&gt;Prowler: Scan runtime Azure
    Prowler--&gt;&gt;MakeReport: HTML + JSON reports
    MakeReport-&gt;&gt;Checkov: Scan ARM JSON
    Checkov--&gt;&gt;MakeReport: SARIF findings
    MakeReport-&gt;&gt;MakeReport: Parse + HTML unificado
    MakeReport--&gt;&gt;User: Dashboard seguridad
    MakeReport-&gt;&gt;Azure: Cleanup SP temporal
</code></pre>

<h2 id="️-componentes-técnicos-en-detalle">🛠️ Componentes Técnicos en Detalle</h2>

<h3 id="docazureautomatic_allinoneps1-el-nuevo-monolito-inteligente">DocAzureAutomatic_AllInOne.ps1: El Nuevo Monolito Inteligente</h3>

<p><strong>Fase 1: Autenticación y Descubrimiento</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Login interactivo con selección de suscripciones</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">login</span><span class="w"> </span><span class="nt">--tenant</span><span class="w"> </span><span class="nv">$TenantId</span><span class="w">

</span><span class="c"># Lista todas las suscripciones del tenant</span><span class="w">
</span><span class="nv">$subscriptions</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">account</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--all</span><span class="w"> </span><span class="nt">--output</span><span class="w"> </span><span class="nx">json</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span></code></pre></div></div>

<p><strong>Fase 2: Exportación Masiva de ARM Templates</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Por cada suscripción → Por cada Resource Group</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$rg</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$resourceGroups</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">az</span><span class="w"> </span><span class="nx">group</span><span class="w"> </span><span class="nx">export</span><span class="w"> </span><span class="nt">--name</span><span class="w"> </span><span class="nv">$rg</span><span class="w"> </span><span class="nt">--include-parameter-default-value</span><span class="w"> </span><span class="nt">--output</span><span class="w"> </span><span class="nx">json</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Genera estructura:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PlantillasARM/
├── Produccion/
│   ├── RG-WebApps_template.json
│   ├── RG-Databases_template.json
│   └── Resumen.md
└── Desarrollo/
    ├── RG-DevEnv_template.json
    └── Resumen.md
</code></pre></div></div>

<p><strong>Fase 3: Procesamiento JSON → Markdown</strong></p>

<p>El script implementa un <strong>flatten algorithm</strong> que descompone objetos JSON anidados en propiedades planas:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Expand-FlattenObject</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">([</span><span class="n">hashtable</span><span class="p">]</span><span class="nv">$Object</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$Prefix</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">''</span><span class="p">)</span><span class="w">
    </span><span class="c"># Convierte { properties: { config: { value: 10 } } }</span><span class="w">
    </span><span class="c"># En: properties.config.value = 10</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Resultado: tablas Markdown legibles con <strong>todos</strong> los detalles de configuración:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">## 💻 mi-vm-produccion — Microsoft.Compute/virtualMachines</span>

| Propiedad | Valor |
|-----------|-------|
| location | westeurope |
| vmSize | Standard_D4s_v3 |
| osType | Linux |
| osDisk.caching | ReadWrite |
| nicId | /subscriptions/.../networkInterfaces/vm-nic |
</code></pre></div></div>

<p><strong>Fase 4: Conversión Python Embebida</strong></p>

<p>El código Python completo está embebido como <strong>here-string</strong> en PowerShell:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$pyCode</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sh">@'
import markdown_it, bs4
# ... todo el código Python ...
'@</span><span class="w">

</span><span class="nv">$tmpPy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Join-Path</span><span class="w"> </span><span class="nv">$</span><span class="nn">env</span><span class="p">:</span><span class="nv">TEMP</span><span class="w"> </span><span class="s2">"md2html_embedded.py"</span><span class="w">
</span><span class="nv">$pyCode</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Set-Content</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="nv">$tmpPy</span><span class="w">
</span><span class="o">&amp;</span><span class="w"> </span><span class="n">python</span><span class="w"> </span><span class="nv">$tmpPy</span><span class="w"> </span><span class="nv">$BaseFolder</span><span class="w">
</span></code></pre></div></div>

<p>Ventajas:</p>
<ul>
  <li>No necesitas un archivo <code class="language-plaintext highlighter-rouge">.py</code> separado</li>
  <li>Todo se distribuye en un solo <code class="language-plaintext highlighter-rouge">.ps1</code></li>
  <li>Funciona incluso si el usuario no tiene permisos de escritura en el directorio de trabajo</li>
</ul>

<h3 id="make-reportv2ps1-el-orchestrator-de-seguridad">make-report.v2.ps1: El Orchestrator de Seguridad</h3>

<p><strong>Innovación 1: Gestión de Service Principal Temporal</strong></p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1) Crear SP con permisos mínimos</span><span class="w">
</span><span class="nv">$sp</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">sp</span><span class="w"> </span><span class="nx">create-for-rbac</span><span class="w"> </span><span class="nt">--name</span><span class="w"> </span><span class="s2">"ProwlerTempSP-</span><span class="nv">$timestamp</span><span class="s2">"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--role</span><span class="w"> </span><span class="s2">"Reader"</span><span class="w"> </span><span class="nt">--scopes</span><span class="w"> </span><span class="s2">"/subscriptions/</span><span class="nv">$subId</span><span class="s2">"</span><span class="w">

</span><span class="c"># 2) Asignar roles adicionales por suscripción</span><span class="w">
</span><span class="n">Add-RolesToSP</span><span class="w"> </span><span class="nt">-ClientId</span><span class="w"> </span><span class="nv">$sp</span><span class="o">.</span><span class="nf">clientId</span><span class="w"> </span><span class="nt">-Roles</span><span class="w"> </span><span class="p">@(</span><span class="s2">"Security Reader"</span><span class="p">,</span><span class="w"> </span><span class="s2">"ProwlerRole"</span><span class="p">)</span><span class="w">

</span><span class="c"># 3) Esperar propagación RBAC (crítico!)</span><span class="w">
</span><span class="n">Start-Sleep</span><span class="w"> </span><span class="nt">-Seconds</span><span class="w"> </span><span class="nx">45</span><span class="w">

</span><span class="c"># 4) Ejecutar scans</span><span class="w">

</span><span class="c"># 5) Cleanup automático</span><span class="w">
</span><span class="n">Delete-SPAndApp</span><span class="w"> </span><span class="nt">-ClientId</span><span class="w"> </span><span class="nv">$sp</span><span class="o">.</span><span class="nf">clientId</span><span class="w">
</span></code></pre></div></div>

<p><strong>¿Por qué un SP temporal?</strong></p>
<ul>
  <li>🔒 Principio de mínimo privilegio: existe solo durante el escaneo</li>
  <li>🧹 Limpieza automática: no deja recursos huérfanos</li>
  <li>🔄 Auditable: cada ejecución crea nuevo SP con timestamp</li>
  <li>👮 Compliance: evita credenciales permanentes de servicio</li>
</ul>

<p><strong>Innovación 2: ProwlerRole Personalizado</strong></p>

<p>Prowler v5.x recomienda un custom role con permisos específicos que Reader no cubre:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"Name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"ProwlerRole"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"IsCustom"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Permisos adicionales para Prowler no cubiertos por Reader"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Actions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="s2">"Microsoft.Web/sites/host/listkeys/action"</span><span class="p">,</span><span class="w">
    </span><span class="s2">"Microsoft.Web/sites/config/list/Action"</span><span class="w">
  </span><span class="p">],</span><span class="w">
  </span><span class="nl">"AssignableScopes"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"/subscriptions/{subscription-id}"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>El script crea esta role automáticamente si no existe y la limpia al finalizar (opcional).</p>

<p><strong>Innovación 3: Resolución Inteligente de Herramientas</strong></p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">Resolve-ToolPath</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="kr">param</span><span class="p">(</span><span class="nv">$ToolName</span><span class="p">,</span><span class="w"> </span><span class="nv">$VenvDir</span><span class="p">,</span><span class="w"> </span><span class="p">[</span><span class="n">switch</span><span class="p">]</span><span class="nv">$PreferVenv</span><span class="p">)</span><span class="w">
    
    </span><span class="c"># 1) Buscar en venv (si PreferVenv=true)</span><span class="w">
    </span><span class="nv">$candidates</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@(</span><span class="w">
        </span><span class="s2">"</span><span class="nv">$VenvDir</span><span class="s2">\Scripts\</span><span class="nv">$ToolName</span><span class="s2">.exe"</span><span class="p">,</span><span class="w">
        </span><span class="s2">"</span><span class="nv">$VenvDir</span><span class="s2">\Scripts\</span><span class="nv">$ToolName</span><span class="s2">.cmd"</span><span class="w">
    </span><span class="p">)</span><span class="w">
    
    </span><span class="c"># 2) Fallback a PATH global</span><span class="w">
    </span><span class="kr">return</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Command</span><span class="w"> </span><span class="nv">$ToolName</span><span class="p">)</span><span class="o">.</span><span class="nf">Source</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Prioridad: <code class="language-plaintext highlighter-rouge">venv → PATH global → error</code></p>

<p><strong>Innovación 4: Staging de Archivos para Checkov</strong></p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">function</span><span class="w"> </span><span class="nf">New-ScanStaging</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="c"># Copia SOLO *.json a un directorio temporal</span><span class="w">
    </span><span class="c"># Evita que Checkov procese .md, .html, etc.</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Sin staging: Checkov analiza <strong>todos</strong> los archivos (lento + falsos positivos)<br />
Con staging: Solo JSON válidos (rápido + preciso)</p>

<p><strong>Innovación 5: Parsing SARIF Avanzado</strong></p>

<p>Checkov genera SARIF (Static Analysis Results Interchange Format). El script extrae:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$result</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$sarif</span><span class="o">.</span><span class="n">runs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="nf">results</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$finding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
        </span><span class="nx">RuleId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$result</span><span class="err">.</span><span class="nx">ruleId</span><span class="w">
        </span><span class="nx">Level</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$result</span><span class="err">.</span><span class="nx">level</span><span class="w">  </span><span class="c"># error/warning/note</span><span class="w">
        </span><span class="nx">Resource</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$result</span><span class="err">.</span><span class="nx">properties</span><span class="err">.</span><span class="nx">resource_name</span><span class="w">
        </span><span class="nx">File</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$result</span><span class="err">.</span><span class="nx">locations</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="err">.</span><span class="nx">physicalLocation</span><span class="err">.</span><span class="nx">artifactLocation</span><span class="err">.</span><span class="nx">uri</span><span class="w">
        </span><span class="nx">RemediationUrl</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$result</span><span class="err">.</span><span class="nx">rule</span><span class="err">.</span><span class="nx">helpUri</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Output final</strong>: HTML interactivo con filtros por severidad y búsqueda full-text:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"filters"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;label&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"f_error"</span> <span class="na">checked</span><span class="nt">&gt;</span> error<span class="nt">&lt;/label&gt;</span>
  <span class="nt">&lt;label&gt;&lt;input</span> <span class="na">type=</span><span class="s">"checkbox"</span> <span class="na">id=</span><span class="s">"f_warning"</span> <span class="na">checked</span><span class="nt">&gt;</span> warning<span class="nt">&lt;/label&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"search"</span> <span class="na">placeholder=</span><span class="s">"buscar recurso, rule, archivo..."</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="-dashboard-de-seguridad-unificado">📊 Dashboard de Seguridad Unificado</h2>

<p>El HTML generado por make-report.v2.ps1 incluye:</p>

<h3 id="panel-de-resumen">Panel de Resumen</h3>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌─────────────────────┬─────────────────────┐
│   Checkov Summary   │   Prowler Summary   │
│                     │                     │
│ • Error: 12         │ • Critical: 5       │
│ • Warning: 34       │ • High: 18          │
│ • Note: 89          │ • Medium: 42        │
│                     │ • Low: 156          │
│                     │ [Ver índice →]      │
└─────────────────────┴─────────────────────┘
</code></pre></div></div>

<h3 id="tabla-de-findings">Tabla de Findings</h3>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Level</th>
      <th>RuleId</th>
      <th>Resource</th>
      <th>File</th>
      <th>Message</th>
      <th>Remediation</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Checkov</td>
      <td>error</td>
      <td>CKV_AZURE_1</td>
      <td>my-storage-account</td>
      <td>rg-prod.json</td>
      <td>Storage account allows public access</td>
      <td>[link →]</td>
    </tr>
    <tr>
      <td>Prowler</td>
      <td>critical</td>
      <td>storage_ensure_secure_transfer</td>
      <td>/subscriptions/…/storageAccounts/mysa</td>
      <td>N/A</td>
      <td>Secure transfer not enabled</td>
      <td>[link →]</td>
    </tr>
  </tbody>
</table>

<h3 id="filtrado-interactivo-javascript">Filtrado Interactivo JavaScript</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Filtros por nivel + búsqueda texto</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">'</span><span class="s1">#t tbody tr</span><span class="dl">'</span><span class="p">).</span><span class="nf">forEach</span><span class="p">(</span><span class="nx">r</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">level</span> <span class="o">=</span> <span class="nx">r</span><span class="p">.</span><span class="nf">getAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">data-level</span><span class="dl">'</span><span class="p">);</span>
    <span class="kd">const</span> <span class="nx">okLevel</span> <span class="o">=</span> <span class="nx">checks</span><span class="p">[</span><span class="nx">level</span><span class="p">].</span><span class="nx">checked</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">okQuery</span> <span class="o">=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">innerText</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">().</span><span class="nf">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">);</span>
    <span class="nx">r</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">display</span> <span class="o">=</span> <span class="p">(</span><span class="nx">okLevel</span> <span class="o">&amp;&amp;</span> <span class="nx">okQuery</span><span class="p">)</span> <span class="p">?</span> <span class="dl">''</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">none</span><span class="dl">'</span><span class="p">;</span>
<span class="p">});</span>
</code></pre></div></div>

<h2 id="-guía-de-implementación-paso-a-paso">🚀 Guía de Implementación Paso a Paso</h2>

<h3 id="requisitos-previos">Requisitos Previos</h3>

<p><strong>Software necesario:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Azure CLI</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Microsoft.AzureCLI</span><span class="w">

</span><span class="c"># Python 3.7+</span><span class="w">
</span><span class="n">winget</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">Python.Python.3.11</span><span class="w">

</span><span class="c"># PowerShell 5.1+ (incluido en Windows 10/11)</span><span class="w">
</span><span class="bp">$PSVersionTable</span><span class="o">.</span><span class="nf">PSVersion</span><span class="w">
</span></code></pre></div></div>

<p><strong>Permisos de Azure necesarios:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Reader</code> en las suscripciones a documentar</li>
  <li><code class="language-plaintext highlighter-rouge">Security Reader</code> para análisis de seguridad con Prowler</li>
  <li><code class="language-plaintext highlighter-rouge">User Access Administrator</code> (solo si quieres crear ProwlerRole)</li>
</ul>

<h3 id="instalación-en-5-minutos">Instalación en 5 Minutos</h3>

<p><strong>Paso 1: Preparar el Proyecto</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Crear directorio y descargar archivos</span><span class="w">
</span><span class="n">mkdir</span><span class="w"> </span><span class="nx">C:\DocAzureAutomatic</span><span class="w">
</span><span class="n">cd</span><span class="w"> </span><span class="nx">C:\DocAzureAutomatic</span><span class="w">

</span><span class="c"># Descargar desde tu repositorio</span><span class="w">
</span><span class="c"># git clone https://github.com/tuusuario/DocAzureAutomatic.git .</span><span class="w">
</span></code></pre></div></div>

<p><strong>Paso 2: Configurar Entorno Virtual Python</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Crear venv</span><span class="w">
</span><span class="n">python</span><span class="w"> </span><span class="nt">-m</span><span class="w"> </span><span class="nx">venv</span><span class="w"> </span><span class="o">.</span><span class="nf">venv</span><span class="w">

</span><span class="c"># Activar (puede requerir cambiar execution policy)</span><span class="w">
</span><span class="n">Set-ExecutionPolicy</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="nx">CurrentUser</span><span class="w"> </span><span class="nx">RemoteSigned</span><span class="w">
</span><span class="o">.</span><span class="n">\.venv\Scripts\Activate.ps1</span><span class="w">

</span><span class="c"># Instalar dependencias</span><span class="w">
</span><span class="n">python</span><span class="w"> </span><span class="nt">-m</span><span class="w"> </span><span class="nx">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nt">--upgrade</span><span class="w"> </span><span class="nx">pip</span><span class="w">
</span><span class="n">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">prowler</span><span class="w"> </span><span class="nx">checkov</span><span class="w"> </span><span class="nx">markdown-it-py</span><span class="w"> </span><span class="nx">beautifulsoup4</span><span class="w">
</span></code></pre></div></div>

<p><strong>Paso 3: Verificar Instalación</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Comprobar herramientas venv</span><span class="w">
</span><span class="n">Test-Path</span><span class="w"> </span><span class="o">.</span><span class="nx">\.venv\Scripts\prowler.exe</span><span class="w">  </span><span class="c"># True</span><span class="w">
</span><span class="n">Test-Path</span><span class="w"> </span><span class="o">.</span><span class="nx">\.venv\Scripts\checkov.cmd</span><span class="w">  </span><span class="c"># True</span><span class="w">

</span><span class="c"># Verificar versiones</span><span class="w">
</span><span class="o">.</span><span class="n">\.venv\Scripts\prowler.exe</span><span class="w"> </span><span class="nt">--version</span><span class="w">   </span><span class="c"># Prowler 5.x</span><span class="w">
</span><span class="o">.</span><span class="n">\.venv\Scripts\checkov.cmd</span><span class="w"> </span><span class="nt">--version</span><span class="w">   </span><span class="c"># Checkov 3.x</span><span class="w">
</span></code></pre></div></div>

<p><strong>Paso 4: Ejecutar Pipeline de Documentación</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ejecutar DocAzureAutomatic unificado</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic_AllInOne.ps1</span><span class="w">

</span><span class="c"># Introduce tu Tenant ID cuando se solicite</span><span class="w">
</span><span class="c"># Selecciona las suscripciones a procesar</span><span class="w">
</span></code></pre></div></div>

<p>Resultado:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PlantillasARM/
├── Produccion/
│   ├── RG-WebApps_template.json
│   ├── RG-WebApps_template.md
│   ├── RG-WebApps_template.html
│   └── styles.css
└── ResumenGlobal.html
</code></pre></div></div>

<p><strong>Paso 5: Ejecutar Análisis de Seguridad</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ejecutar con herramientas del venv</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w"> </span><span class="nt">-VenvDir</span><span class="w"> </span><span class="s2">".\.venv"</span><span class="w">

</span><span class="c"># Opciones útiles:</span><span class="w">
</span><span class="c"># -UseTempSP $true              # Crear SP temporal (recomendado)</span><span class="w">
</span><span class="c"># -EnsureProwlerRole $true      # Crear ProwlerRole custom</span><span class="w">
</span><span class="c"># -ProwlerSubscriptionIds "id1,id2"  # Limitar suscripciones</span><span class="w">
</span></code></pre></div></div>

<p>Resultado:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>reports/
├── checkov/
│   ├── results.sarif
│   └── index.html          # ← Dashboard principal
└── prowler/
    ├── azure_*.html
    └── prowler-output-*.json
</code></pre></div></div>

<h3 id="automatización-con-azure-devops">Automatización con Azure DevOps</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># azure-pipelines.yml</span>
<span class="na">trigger</span><span class="pi">:</span>
  <span class="na">branches</span><span class="pi">:</span>
    <span class="na">include</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">pool</span><span class="pi">:</span>
  <span class="na">vmImage</span><span class="pi">:</span> <span class="s1">'</span><span class="s">windows-latest'</span>

<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">UsePythonVersion@0</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">versionSpec</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.11'</span>

<span class="pi">-</span> <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">python -m venv .venv</span>
    <span class="s">.\.venv\Scripts\Activate.ps1</span>
    <span class="s">pip install prowler checkov markdown-it-py beautifulsoup4</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Setup</span><span class="nv"> </span><span class="s">Python</span><span class="nv"> </span><span class="s">venv'</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">AzureCLI@2</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">azureSubscription</span><span class="pi">:</span> <span class="s1">'</span><span class="s">MyAzureConnection'</span>
    <span class="na">scriptType</span><span class="pi">:</span> <span class="s1">'</span><span class="s">ps'</span>
    <span class="na">scriptLocation</span><span class="pi">:</span> <span class="s1">'</span><span class="s">inlineScript'</span>
    <span class="na">inlineScript</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">.\DocAzureAutomatic_AllInOne.ps1 -TenantId $(tenantId) -SubscriptionIds $(subscriptionIds)</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Generate</span><span class="nv"> </span><span class="s">Documentation'</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PowerShell@2</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">filePath</span><span class="pi">:</span> <span class="s1">'</span><span class="s">make-report.v2.ps1'</span>
    <span class="na">arguments</span><span class="pi">:</span> <span class="s1">'</span><span class="s">-PreferVenvTools</span><span class="nv"> </span><span class="s">-VenvDir</span><span class="nv"> </span><span class="s">.\.venv</span><span class="nv"> </span><span class="s">-UseTempSP</span><span class="nv"> </span><span class="s">$true'</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Security</span><span class="nv"> </span><span class="s">Analysis'</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishBuildArtifacts@1</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">PathtoPublish</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(System.DefaultWorkingDirectory)'</span>
    <span class="na">ArtifactName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">azure-security-docs'</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Publish</span><span class="nv"> </span><span class="s">Reports'</span>
</code></pre></div></div>

<h2 id="-deep-dive-qué-analiza-cada-herramienta">🔍 Deep Dive: ¿Qué Analiza Cada Herramienta?</h2>

<h3 id="prowler-runtime-security-posture">Prowler: Runtime Security Posture</h3>

<p><strong>Categorías de checks:</strong></p>
<ul>
  <li>🔐 <strong>Identity &amp; Access</strong>: MFA, privileged accounts, stale users</li>
  <li>🌐 <strong>Networking</strong>: NSG rules, open ports, public IPs</li>
  <li>💾 <strong>Storage</strong>: Encryption, public access, logging</li>
  <li>🗄️ <strong>Databases</strong>: TLS, auditing, vulnerability assessments</li>
  <li>🔑 <strong>Key Vault</strong>: access policies, soft delete, purge protection</li>
  <li>📊 <strong>Monitoring</strong>: Log Analytics, alerts, diagnostic settings</li>
  <li>🛡️ <strong>Security Center</strong>: Defender plans, recommendations</li>
</ul>

<p><strong>Ejemplo de finding Prowler:</strong></p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"CheckID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"storage_ensure_secure_transfer_enabled"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"CheckTitle"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Ensure secure transfer is required for storage accounts"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Status"</span><span class="p">:</span><span class="w"> </span><span class="s2">"FAIL"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"StatusExtended"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Storage account 'mystorageaccount' does not require secure transfer"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"ResourceId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/subscriptions/.../storageAccounts/mystorageaccount"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Severity"</span><span class="p">:</span><span class="w"> </span><span class="s2">"high"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"Remediation"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"Code"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"CLI"</span><span class="p">:</span><span class="w"> </span><span class="s2">"az storage account update --name mystorageaccount --https-only true"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"PowerShell"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Set-AzStorageAccount -Name mystorageaccount -EnableHttpsTrafficOnly $true"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h3 id="checkov-infrastructure-as-code-compliance">Checkov: Infrastructure as Code Compliance</h3>

<p><strong>Frameworks soportados:</strong></p>
<ul>
  <li>CIS Azure Foundations Benchmark</li>
  <li>PCI-DSS</li>
  <li>HIPAA</li>
  <li>SOC 2</li>
  <li>NIST 800-53</li>
  <li>ISO 27001</li>
  <li>GDPR</li>
</ul>

<p><strong>Tipos de checks:</strong></p>
<ul>
  <li>⚙️ <strong>Configuration</strong>: Tamaño de VM, SKUs, regiones</li>
  <li>🔐 <strong>Security</strong>: Encryption, access controls, network rules</li>
  <li>💰 <strong>Cost Optimization</strong>: Over-provisioned resources</li>
  <li>🏷️ <strong>Tagging</strong>: Mandatory tags, naming conventions</li>
  <li>🔄 <strong>Dependencies</strong>: Resource relationships</li>
</ul>

<p><strong>Ejemplo de check Checkov:</strong></p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Check CKV_AZURE_35: Storage account uses customer-managed keys
</span><span class="p">{</span>
  <span class="sh">"</span><span class="s">check_id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">CKV_AZURE_35</span><span class="sh">"</span><span class="p">,</span>
  <span class="sh">"</span><span class="s">bc_check_id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">BC_AZR_GENERAL_35</span><span class="sh">"</span><span class="p">,</span>
  <span class="sh">"</span><span class="s">check_name</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">Storage account encryption uses customer-managed key</span><span class="sh">"</span><span class="p">,</span>
  <span class="sh">"</span><span class="s">check_result</span><span class="sh">"</span><span class="p">:</span> <span class="p">{</span>
    <span class="sh">"</span><span class="s">result</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">failed</span><span class="sh">"</span><span class="p">,</span>
    <span class="sh">"</span><span class="s">evaluated_keys</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span><span class="sh">"</span><span class="s">properties/encryption/keySource</span><span class="sh">"</span><span class="p">]</span>
  <span class="p">},</span>
  <span class="sh">"</span><span class="s">file_path</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">/PlantillasARM/Produccion/RG-Storage_template.json</span><span class="sh">"</span><span class="p">,</span>
  <span class="sh">"</span><span class="s">resource</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">storageAccounts/mystorage</span><span class="sh">"</span><span class="p">,</span>
  <span class="sh">"</span><span class="s">guideline</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://docs.bridgecrew.io/docs/bc_azr_general_35</span><span class="sh">"</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="-casos-de-uso-empresariales">🎓 Casos de Uso Empresariales</h2>

<h3 id="1-auditoría-trimestral-de-compliance">1. <strong>Auditoría Trimestral de Compliance</strong></h3>

<p><strong>Escenario</strong>: Empresa financiera con certificación ISO 27001 necesita demostrar compliance cada trimestre.</p>

<p><strong>Solución con DocAzureAutomatic v2:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Programar en Task Scheduler cada 90 días</span><span class="w">
</span><span class="nv">$trigger</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-ScheduledTaskTrigger</span><span class="w"> </span><span class="nt">-Weekly</span><span class="w"> </span><span class="nt">-DaysOfWeek</span><span class="w"> </span><span class="nx">Monday</span><span class="w"> </span><span class="nt">-At</span><span class="w"> </span><span class="nx">2am</span><span class="w">
</span><span class="nv">$action</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">New-ScheduledTaskAction</span><span class="w"> </span><span class="nt">-Execute</span><span class="w"> </span><span class="s2">"PowerShell.exe"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Argument</span><span class="w"> </span><span class="s2">"-File C:\DocAzureAutomatic\run-audit.ps1"</span><span class="w">

</span><span class="n">Register-ScheduledTask</span><span class="w"> </span><span class="nt">-TaskName</span><span class="w"> </span><span class="s2">"Azure Quarterly Audit"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Trigger</span><span class="w"> </span><span class="nv">$trigger</span><span class="w"> </span><span class="nt">-Action</span><span class="w"> </span><span class="nv">$action</span><span class="w">
</span></code></pre></div></div>

<p><strong>run-audit.ps1:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">\DocAzureAutomatic_AllInOne.ps1</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nv">$tid</span><span class="w"> </span><span class="nt">-SubscriptionIds</span><span class="w"> </span><span class="nv">$sids</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w"> </span><span class="nt">-VenvDir</span><span class="w"> </span><span class="s2">".\.venv"</span><span class="w">

</span><span class="c"># Archivar resultados con timestamp</span><span class="w">
</span><span class="nv">$date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Date</span><span class="w"> </span><span class="nt">-Format</span><span class="w"> </span><span class="s2">"yyyy-MM-dd"</span><span class="w">
</span><span class="n">Compress-Archive</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">".\reports"</span><span class="w"> </span><span class="nt">-DestinationPath</span><span class="w"> </span><span class="s2">".\archives\audit-</span><span class="nv">$date</span><span class="s2">.zip"</span><span class="w">

</span><span class="c"># Enviar email con resultados</span><span class="w">
</span><span class="n">Send-MailMessage</span><span class="w"> </span><span class="nt">-To</span><span class="w"> </span><span class="s2">"compliance@company.com"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Subject</span><span class="w"> </span><span class="s2">"Azure Audit Report </span><span class="nv">$date</span><span class="s2">"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-Attachments</span><span class="w"> </span><span class="s2">".\archives\audit-</span><span class="nv">$date</span><span class="s2">.zip"</span><span class="w">
</span></code></pre></div></div>

<p><strong>ROI</strong>:</p>
<ul>
  <li>Antes: 40 horas/persona por auditoría manual</li>
  <li>Después: 2 horas de review de reportes automatizados</li>
  <li><strong>Ahorro</strong>: 95% de tiempo + reducción de errores humanos</li>
</ul>

<h3 id="2-cicd-con-security-gates">2. <strong>CI/CD con Security Gates</strong></h3>

<p><strong>Escenario</strong>: Pipeline de despliegue debe validar seguridad antes de aprobar cambios a producción.</p>

<p><strong>Azure DevOps Pipeline:</strong></p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">stages</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s">SecurityValidation</span>
  <span class="na">jobs</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">job</span><span class="pi">:</span> <span class="s">SecurityScan</span>
    <span class="na">steps</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">.\make-report.v2.ps1 -ScanRoot ".\arm-templates" -PreferVenvTools</span>
      <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Security</span><span class="nv"> </span><span class="s">Scan'</span>
    
    <span class="pi">-</span> <span class="na">powershell</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">$sarif = Get-Content .\reports\checkov\results.sarif | ConvertFrom-Json</span>
        <span class="s">$errors = $sarif.runs[0].results | Where-Object {$_.level -eq "error"}</span>
        <span class="s">if ($errors.Count -gt 0) {</span>
          <span class="s">Write-Error "❌ Security check failed: $($errors.Count) critical issues found"</span>
          <span class="s">exit 1</span>
        <span class="s">}</span>
      <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Validate</span><span class="nv"> </span><span class="s">Findings'</span>

<span class="pi">-</span> <span class="na">stage</span><span class="pi">:</span> <span class="s">Deploy</span>
  <span class="na">dependsOn</span><span class="pi">:</span> <span class="s">SecurityValidation</span>
  <span class="na">condition</span><span class="pi">:</span> <span class="s">succeeded()</span>
  <span class="c1"># ... deployment steps</span>
</code></pre></div></div>

<p><strong>Resultado</strong>: Despliegues bloqueados automáticamente si hay vulnerabilidades críticas.</p>

<h3 id="3-onboarding-de-clientes-en-msp">3. <strong>Onboarding de Clientes en MSP</strong></h3>

<p><strong>Escenario</strong>: Managed Service Provider necesita documentar infraestructura de nuevos clientes rápidamente.</p>

<p><strong>Workflow:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1) Obtener acceso guest en tenant del cliente</span><span class="w">
</span><span class="c"># 2) Ejecutar pipeline completo</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic_AllInOne.ps1</span><span class="w"> </span><span class="nt">-TenantId</span><span class="w"> </span><span class="nv">$clientTenantId</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w">

</span><span class="c"># 3) Generar "Customer Onboarding Package"</span><span class="w">
</span><span class="nv">$package</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Cliente</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"ACME Corp"</span><span class="w">
    </span><span class="nx">Fecha</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">Get</span><span class="err">-</span><span class="nx">Date</span><span class="w">
    </span><span class="nx">Documentacion</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".\PlantillasARM"</span><span class="w">
    </span><span class="nx">SeguridadCheckov</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".\reports\checkov\index.html"</span><span class="w">
    </span><span class="nx">SeguridadProwler</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">".\reports\prowler\index.html"</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 4) Subir a SharePoint del cliente</span><span class="w">
</span><span class="n">Upload-ToSharePoint</span><span class="w"> </span><span class="nt">-Package</span><span class="w"> </span><span class="nv">$package</span><span class="w"> </span><span class="nt">-SiteUrl</span><span class="w"> </span><span class="nv">$clientSharePoint</span><span class="w">
</span></code></pre></div></div>

<p><strong>Valor añadido</strong>:</p>
<ul>
  <li>Primera reunión con datos concretos</li>
  <li>Identificación temprana de security gaps</li>
  <li>Plan de remediación priorizado desde día 1</li>
</ul>

<h3 id="4-finops-análisis-de-costos-por-compliance">4. <strong>FinOps: Análisis de Costos por Compliance</strong></h3>

<p><strong>Escenario</strong>: Recursos no compliant suelen ser más costosos (over-provisioned, redundancia innecesaria).</p>

<p><strong>Script de análisis:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Correlacionar findings de Checkov con costos</span><span class="w">
</span><span class="nv">$sarif</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Content</span><span class="w"> </span><span class="o">.</span><span class="nx">\reports\checkov\results.sarif</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ConvertFrom-Json</span><span class="w">
</span><span class="nv">$findings</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$sarif</span><span class="o">.</span><span class="n">runs</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="nf">results</span><span class="w">

</span><span class="nv">$costFindings</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$findings</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="o">.</span><span class="nf">ruleId</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"CKV.*COST"</span><span class="w"> </span><span class="o">-or</span><span class="w"> 
    </span><span class="bp">$_</span><span class="o">.</span><span class="nf">properties</span><span class="o">.</span><span class="nf">guideline</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"optimization"</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$f</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$costFindings</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$resource</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$f</span><span class="o">.</span><span class="nf">properties</span><span class="o">.</span><span class="nf">resource_name</span><span class="w">
    </span><span class="c"># Consultar Azure Cost Management API</span><span class="w">
    </span><span class="nv">$cost</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzConsumptionUsageDetail</span><span class="w"> </span><span class="nt">-ResourceId</span><span class="w"> </span><span class="nv">$resource</span><span class="w">
    
    </span><span class="p">[</span><span class="n">PSCustomObject</span><span class="p">]@{</span><span class="w">
        </span><span class="nx">Resource</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$resource</span><span class="w">
        </span><span class="nx">MonthlySpend</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$cost</span><span class="err">.</span><span class="nx">PreTaxCost</span><span class="w">
        </span><span class="nx">Issue</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$f</span><span class="err">.</span><span class="nx">message</span><span class="err">.</span><span class="nx">text</span><span class="w">
        </span><span class="nx">PotentialSaving</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$cost</span><span class="err">.</span><span class="nx">PreTaxCost</span><span class="w"> </span><span class="err">*</span><span class="w"> </span><span class="mf">0.30</span><span class="w">  </span><span class="c"># estimación 30%</span><span class="w">
    </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Output</strong>:</p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Resource                    MonthlySpend  Issue                           PotentialSaving
--------                    ------------  -----                           ---------------
my-oversized-vm                  $450    VM size exceeds workload needs        $135
storage-premium-unnecessary      $280    Premium tier not required             $84
</code></pre></div></div>

<h2 id="-métricas-y-kpis-de-seguridad">📈 Métricas y KPIs de Seguridad</h2>

<p>El sistema genera métricas accionables:</p>

<h3 id="dashboard-de-evolución-temporal">Dashboard de Evolución Temporal</h3>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ejecutar semanalmente y almacenar métricas</span><span class="w">
</span><span class="nv">$metrics</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@{</span><span class="w">
    </span><span class="nx">Fecha</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">Get</span><span class="err">-</span><span class="nx">Date</span><span class="w">
    </span><span class="nx">TotalFindings</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$findings</span><span class="err">.</span><span class="nx">Count</span><span class="w">
    </span><span class="nx">Critical</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nv">$findings</span><span class="w"> </span><span class="err">|</span><span class="w"> </span><span class="nx">Where</span><span class="err">-</span><span class="nx">Object</span><span class="w"> </span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">level</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"error"</span><span class="p">}</span><span class="err">).</span><span class="nx">Count</span><span class="w">
    </span><span class="nx">High</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nv">$findings</span><span class="w"> </span><span class="err">|</span><span class="w"> </span><span class="nx">Where</span><span class="err">-</span><span class="nx">Object</span><span class="w"> </span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">level</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"warning"</span><span class="p">}</span><span class="err">).</span><span class="nx">Count</span><span class="w">
    </span><span class="nx">MeanTimeToRemediate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="nx">Get</span><span class="err">-</span><span class="nx">RemediationMetrics</span><span class="err">).</span><span class="nx">AvgDays</span><span class="w">
    </span><span class="nx">ComplianceScore</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="err">(</span><span class="mi">1</span><span class="w"> </span><span class="err">-</span><span class="w"> </span><span class="err">(</span><span class="nv">$Critical</span><span class="w"> </span><span class="err">/</span><span class="w"> </span><span class="nv">$TotalFindings</span><span class="err">))</span><span class="w"> </span><span class="err">*</span><span class="w"> </span><span class="mi">100</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="nv">$metrics</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Export-Csv</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">".\metrics\security-trends.csv"</span><span class="w"> </span><span class="nt">-Append</span><span class="w">
</span></code></pre></div></div>

<h3 id="visualización-con-power-bi">Visualización con Power BI</h3>
<p>Conectar el CSV a Power BI Desktop y crear dashboard:</p>
<ul>
  <li>📊 Tendencia de findings por semana</li>
  <li>🎯 Compliance score evolution</li>
  <li>🔥 Top 10 recursos más vulnerables</li>
  <li>⏱️ Time-to-fix por tipo de vulnerabilidad</li>
  <li>👥 Findings por equipo/suscripción</li>
</ul>

<h2 id="️-best-practices-de-seguridad">🛡️ Best Practices de Seguridad</h2>

<h3 id="1-gestión-segura-de-credenciales">1. <strong>Gestión Segura de Credenciales</strong></h3>

<p><strong>❌ MAL: Credenciales hardcodeadas</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$password</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"SuperSecretP@ssw0rd!"</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">login</span><span class="w"> </span><span class="nt">-u</span><span class="w"> </span><span class="nx">admin</span><span class="err">@</span><span class="nx">company.com</span><span class="w"> </span><span class="nt">-p</span><span class="w"> </span><span class="nv">$password</span><span class="w">
</span></code></pre></div></div>

<p><strong>✅ BIEN: Azure Managed Identity</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># En Azure VM o Azure DevOps</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">login</span><span class="w"> </span><span class="nt">--identity</span><span class="w">

</span><span class="c"># En local: Device Code Flow (interactivo)</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">login</span><span class="w"> </span><span class="nt">--use-device-code</span><span class="w">
</span></code></pre></div></div>

<p><strong>✅ MEJOR: Service Principal con Azure Key Vault</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$clientId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">keyvault</span><span class="w"> </span><span class="nx">secret</span><span class="w"> </span><span class="nx">show</span><span class="w"> </span><span class="nt">--vault-name</span><span class="w"> </span><span class="nx">myvault</span><span class="w"> </span><span class="nt">--name</span><span class="w"> </span><span class="nx">sp-client-id</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="nx">value</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="w">
</span><span class="nv">$clientSecret</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">keyvault</span><span class="w"> </span><span class="nx">secret</span><span class="w"> </span><span class="nx">show</span><span class="w"> </span><span class="nt">--vault-name</span><span class="w"> </span><span class="nx">myvault</span><span class="w"> </span><span class="nt">--name</span><span class="w"> </span><span class="nx">sp-client-secret</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="nx">value</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">login</span><span class="w"> </span><span class="nt">--service-principal</span><span class="w"> </span><span class="nt">-u</span><span class="w"> </span><span class="nv">$clientId</span><span class="w"> </span><span class="nt">-p</span><span class="w"> </span><span class="nv">$clientSecret</span><span class="w"> </span><span class="nt">--tenant</span><span class="w"> </span><span class="nv">$tenantId</span><span class="w">
</span></code></pre></div></div>

<h3 id="2-principio-de-mínimo-privilegio">2. <strong>Principio de Mínimo Privilegio</strong></h3>

<p>El SP temporal creado por make-report.v2.ps1 solo tiene:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">Reader</code> (lectura de recursos)</li>
  <li><code class="language-plaintext highlighter-rouge">Security Reader</code> (acceso a Security Center)</li>
  <li><code class="language-plaintext highlighter-rouge">ProwlerRole</code> (acciones específicas de lectura)</li>
</ul>

<p><strong>NUNCA</strong> des <code class="language-plaintext highlighter-rouge">Contributor</code> o <code class="language-plaintext highlighter-rouge">Owner</code> a SPs de scanning.</p>

<h3 id="3-encriptación-de-reportes-sensibles">3. <strong>Encriptación de Reportes Sensibles</strong></h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Encriptar reportes con certificado corporativo</span><span class="w">
</span><span class="nv">$cert</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-ChildItem</span><span class="w"> </span><span class="nx">Cert:\CurrentUser\My</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="bp">$_</span><span class="o">.</span><span class="nf">Subject</span><span class="w"> </span><span class="o">-match</span><span class="w"> </span><span class="s2">"security"</span><span class="p">}</span><span class="w">
</span><span class="n">Protect-CmsMessage</span><span class="w"> </span><span class="nt">-To</span><span class="w"> </span><span class="nv">$cert</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">".\reports\checkov\index.html"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">-OutFile</span><span class="w"> </span><span class="s2">".\reports\checkov\index.html.encrypted"</span><span class="w">

</span><span class="c"># Solo usuarios con el certificado pueden desencriptar</span><span class="w">
</span><span class="n">Unprotect-CmsMessage</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="s2">".\reports\checkov\index.html.encrypted"</span><span class="w">
</span></code></pre></div></div>

<h3 id="4-retención-y-archivado-de-auditorías">4. <strong>Retención y Archivado de Auditorías</strong></h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Política de retención: 7 años para compliance</span><span class="w">
</span><span class="nv">$archiveDate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">Get-Date</span><span class="p">)</span><span class="o">.</span><span class="nf">AddDays</span><span class="p">(</span><span class="nt">-2555</span><span class="p">)</span><span class="w">  </span><span class="c"># 7 años</span><span class="w">

</span><span class="n">Get-ChildItem</span><span class="w"> </span><span class="s2">".\archives"</span><span class="w"> </span><span class="nt">-Filter</span><span class="w"> </span><span class="s2">"audit-*.zip"</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="bp">$_</span><span class="o">.</span><span class="nf">LastWriteTime</span><span class="w"> </span><span class="o">-lt</span><span class="w"> </span><span class="nv">$archiveDate</span><span class="w">
</span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="c"># Mover a Azure Blob Storage (Archive tier) antes de borrar local</span><span class="w">
    </span><span class="n">az</span><span class="w"> </span><span class="nx">storage</span><span class="w"> </span><span class="nx">blob</span><span class="w"> </span><span class="nx">upload</span><span class="w"> </span><span class="nt">--account-name</span><span class="w"> </span><span class="nv">$storageAccount</span><span class="w"> </span><span class="se">`
</span><span class="w">        </span><span class="nt">--container-name</span><span class="w"> </span><span class="s2">"audit-archives"</span><span class="w"> </span><span class="nt">--file</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">FullName</span><span class="w"> </span><span class="se">`
</span><span class="w">        </span><span class="nt">--tier</span><span class="w"> </span><span class="nx">Archive</span><span class="w">
    
    </span><span class="n">Remove-Item</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">FullName</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="-troubleshooting-común">🔧 Troubleshooting Común</h2>

<h3 id="error-prowlercheckov-not-found">Error: “Prowler/Checkov not found”</h3>

<p><strong>Síntoma</strong>: Script dice que no encuentra las herramientas.</p>

<p><strong>Solución 1</strong>: Verificar instalación en venv</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Activar venv</span><span class="w">
</span><span class="o">.</span><span class="n">\.venv\Scripts\Activate.ps1</span><span class="w">

</span><span class="c"># Reinstalar herramientas</span><span class="w">
</span><span class="n">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nt">--upgrade</span><span class="w"> </span><span class="nx">prowler</span><span class="w"> </span><span class="nx">checkov</span><span class="w">

</span><span class="c"># Verificar instalación</span><span class="w">
</span><span class="n">where.exe</span><span class="w"> </span><span class="nx">prowler</span><span class="w">  </span><span class="c"># debe mostrar ruta en .venv</span><span class="w">
</span><span class="n">where.exe</span><span class="w"> </span><span class="nx">checkov</span><span class="w">
</span></code></pre></div></div>

<p><strong>Solución 2</strong>: Ejecutar con flag correcto</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Forzar uso de venv</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w"> </span><span class="bp">$true</span><span class="w"> </span><span class="nt">-VenvDir</span><span class="w"> </span><span class="s2">".\.venv"</span><span class="w">
</span></code></pre></div></div>

<h3 id="error-insufficient-privileges-for-prowler">Error: “Insufficient privileges for Prowler”</h3>

<p><strong>Síntoma</strong>: Prowler falla con errores de permisos.</p>

<p><strong>Diagnóstico</strong>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verificar roles actuales del SP</span><span class="w">
</span><span class="nv">$spId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">az</span><span class="w"> </span><span class="nx">ad</span><span class="w"> </span><span class="nx">sp</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--display-name</span><span class="w"> </span><span class="s2">"ProwlerTempSP-*"</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="s2">"[0].id"</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">role</span><span class="w"> </span><span class="nx">assignment</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--assignee</span><span class="w"> </span><span class="nv">$spId</span><span class="w"> </span><span class="nt">--all</span><span class="w">

</span><span class="c"># Debe mostrar:</span><span class="w">
</span><span class="c"># - Reader en /subscriptions/{id}</span><span class="w">
</span><span class="c"># - Security Reader en /subscriptions/{id}</span><span class="w">
</span><span class="c"># - ProwlerRole en /subscriptions/{id}</span><span class="w">
</span></code></pre></div></div>

<p><strong>Solución</strong>: Asegurar que ProwlerRole existe y está asignado</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Forzar creación de ProwlerRole</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-EnsureProwlerRole</span><span class="w"> </span><span class="bp">$true</span><span class="w">
</span></code></pre></div></div>

<h3 id="error-rbac-propagation-delay">Error: “RBAC propagation delay”</h3>

<p><strong>Síntoma</strong>: Prowler falla inmediatamente después de crear SP.</p>

<p><strong>Explicación</strong>: Azure RBAC no es instantáneo. Puede tardar hasta 5 minutos en propagarse.</p>

<p><strong>Solución</strong>: Aumentar tiempo de espera</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># En make-report.v2.ps1</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-RbacPropagationSleepSeconds</span><span class="w"> </span><span class="nx">90</span><span class="w">  </span><span class="c"># 90 segundos</span><span class="w">
</span></code></pre></div></div>

<h3 id="error-python-module-not-found-markdown_it">Error: “Python module not found: markdown_it”</h3>

<p><strong>Síntoma</strong>: AllInOne.ps1 falla al intentar convertir MD→HTML.</p>

<p><strong>Solución</strong>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Instalar dependencias de conversión</span><span class="w">
</span><span class="n">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">markdown-it-py</span><span class="w"> </span><span class="nx">beautifulsoup4</span><span class="w">

</span><span class="c"># Verificar instalación</span><span class="w">
</span><span class="n">python</span><span class="w"> </span><span class="nt">-c</span><span class="w"> </span><span class="s2">"import markdown_it, bs4; print('OK')"</span><span class="w">
</span></code></pre></div></div>

<h3 id="error-access-denied-to-subscription">Error: “Access denied to subscription”</h3>

<p><strong>Síntoma</strong>: Script no puede listar recursos de una suscripción.</p>

<p><strong>Diagnóstico</strong>:</p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ver suscripciones accesibles</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">account</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="s2">"[].{Name:name, ID:id, State:state}"</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">table</span><span class="w">

</span><span class="c"># Verificar tu rol en suscripción específica</span><span class="w">
</span><span class="n">az</span><span class="w"> </span><span class="nx">role</span><span class="w"> </span><span class="nx">assignment</span><span class="w"> </span><span class="nx">list</span><span class="w"> </span><span class="nt">--assignee</span><span class="w"> </span><span class="p">(</span><span class="n">az</span><span class="w"> </span><span class="nx">account</span><span class="w"> </span><span class="nx">show</span><span class="w"> </span><span class="nt">--query</span><span class="w"> </span><span class="nx">user.name</span><span class="w"> </span><span class="nt">-o</span><span class="w"> </span><span class="nx">tsv</span><span class="p">)</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="nt">--scope</span><span class="w"> </span><span class="s2">"/subscriptions/{subscription-id}"</span><span class="w">
</span></code></pre></div></div>

<p><strong>Solución</strong>: Pedir a admin que te asigne rol <code class="language-plaintext highlighter-rouge">Reader</code> mínimo.</p>

<h2 id="-extensiones-y-roadmap">🌍 Extensiones y Roadmap</h2>

<h3 id="corto-plazo-q1-2026">Corto Plazo (Q1 2026)</h3>
<ul>
  <li>✅ <strong>Multi-cloud</strong>: Extensión para AWS (boto3) y GCP (gcloud)</li>
  <li>✅ <strong>API REST</strong>: Exponer findings vía API para integraciones</li>
  <li>✅ <strong>Slack/Teams</strong>: Notificaciones de critical findings en tiempo real</li>
  <li>✅ <strong>Terraform/Bicep</strong>: Soporte para parsear estos IaC formats</li>
</ul>

<h3 id="medio-plazo-q2-q3-2026">Medio Plazo (Q2-Q3 2026)</h3>
<ul>
  <li>🔄 <strong>Remediation automática</strong>: Scripts de fix para issues comunes</li>
  <li>📊 <strong>Trend analysis</strong>: ML para predecir degradación de security posture</li>
  <li>🔗 <strong>SIEM integration</strong>: Export a Splunk, Sentinel, QRadar</li>
  <li>🎯 <strong>Policy as Code</strong>: Definir thresholds personalizados por equipo</li>
</ul>

<h3 id="largo-plazo-2026">Largo Plazo (2026+)</h3>
<ul>
  <li>🤖 <strong>AI-powered</strong>: GPT-4 para explicar findings en lenguaje natural</li>
  <li>🌐 <strong>SaaS version</strong>: Portal web con multi-tenancy</li>
  <li>📱 <strong>Mobile app</strong>: Dashboard iOS/Android para CISOs on-the-go</li>
  <li>🔐 <strong>Zero Trust</strong>: Integración con Azure AD Conditional Access</li>
</ul>

<h2 id="-recursos-y-comunidad">📚 Recursos y Comunidad</h2>

<h3 id="documentación-oficial">Documentación Oficial</h3>
<ul>
  <li><strong>Prowler</strong>: <a href="https://github.com/prowler-cloud/prowler">https://github.com/prowler-cloud/prowler</a></li>
  <li><strong>Checkov</strong>: <a href="https://www.checkov.io/">https://www.checkov.io/</a></li>
  <li><strong>Azure CLI</strong>: <a href="https://docs.microsoft.com/cli/azure/">https://docs.microsoft.com/cli/azure/</a></li>
  <li><strong>SARIF Spec</strong>: <a href="https://sarifweb.azurewebsites.net/">https://sarifweb.azurewebsites.net/</a></li>
</ul>

<h3 id="comunidad-docazureautomatic">Comunidad DocAzureAutomatic</h3>
<ul>
  <li><strong>GitHub</strong>: <a href="https://github.com/luisadanmunoz/DocAzureAutomatic">https://github.com/luisadanmunoz/DocAzureAutomatic</a></li>
  <li><strong>Issues</strong>: Reporta bugs y solicita features</li>
  <li><strong>Discussions</strong>: Comparte casos de uso y mejores prácticas</li>
  <li><strong>Pull Requests</strong>: Contribuye con mejoras</li>
</ul>

<h3 id="training-y-certificaciones-recomendadas">Training y Certificaciones Recomendadas</h3>
<ul>
  <li><strong>AZ-500</strong>: Azure Security Engineer Associate</li>
  <li><strong>Certified Cloud Security Professional (CCSP)</strong></li>
  <li><strong>Prowler Certification</strong> (cuando esté disponible)</li>
  <li><strong>DevSecOps Foundation</strong> (DevOps Institute)</li>
</ul>

<h2 id="-conclusión-seguridad-como-código">💡 Conclusión: Seguridad como Código</h2>

<p>DocAzureAutomatic v2 representa un cambio de paradigma:</p>

<p><strong>De → A:</strong></p>
<ul>
  <li>Documentación manual → Automatizada</li>
  <li>Auditorías trimestrales → Continuous compliance</li>
  <li>Silos de seguridad → Integración DevSecOps</li>
  <li>Reactivo → Proactivo</li>
  <li>Checklist estático → Intelligence dinámica</li>
</ul>

<h3 id="el-valor-real">El Valor Real</h3>

<p>No es solo sobre generar reportes bonitos. Es sobre:</p>
<ol>
  <li><strong>Visibilidad</strong>: Saber exactamente qué tienes y cómo de seguro está</li>
  <li><strong>Velocidad</strong>: Pasar de semanas a minutos en compliance</li>
  <li><strong>Calidad</strong>: Eliminar errores humanos en documentación</li>
  <li><strong>Confianza</strong>: Dormir tranquilo sabiendo que tienes control</li>
</ol>

<h3 id="el-siguiente-paso">El Siguiente Paso</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Tu infraestructura te está esperando</span><span class="w">
</span><span class="n">cd</span><span class="w"> </span><span class="nx">C:\DocAzureAutomatic</span><span class="w">
</span><span class="o">.</span><span class="n">\.venv\Scripts\Activate.ps1</span><span class="w">

</span><span class="c"># Documenta</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic_AllInOne.ps1</span><span class="w">

</span><span class="c"># Asegura</span><span class="w">
</span><span class="o">.</span><span class="n">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w"> </span><span class="nt">-VenvDir</span><span class="w"> </span><span class="s2">".\.venv"</span><span class="w">

</span><span class="c"># Mejora</span><span class="w">
</span><span class="c"># (aquí es donde empieza el verdadero trabajo)</span><span class="w">
</span></code></pre></div></div>

<hr />

<h2 id="-comienza-hoy">🚀 ¡Comienza Hoy!</h2>

<p><strong>Instalación en 3 comandos:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">git</span><span class="w"> </span><span class="nx">clone</span><span class="w"> </span><span class="nx">https://github.com/luisadanmunoz/DocAzureAutomatic.git</span><span class="w">
</span><span class="n">cd</span><span class="w"> </span><span class="nx">DocAzureAutomatic</span><span class="w">
</span><span class="n">python</span><span class="w"> </span><span class="nt">-m</span><span class="w"> </span><span class="nx">venv</span><span class="w"> </span><span class="o">.</span><span class="nf">venv</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="o">.</span><span class="nx">\.venv\Scripts\Activate.ps1</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="nx">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">prowler</span><span class="w"> </span><span class="nx">checkov</span><span class="w"> </span><span class="nx">markdown-it-py</span><span class="w"> </span><span class="nx">beautifulsoup4</span><span class="w">
</span></code></pre></div></div>

<p><strong>Primera ejecución:</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">\DocAzureAutomatic_AllInOne.ps1</span><span class="w">
</span><span class="o">.</span><span class="nx">\make-report.v2.ps1</span><span class="w"> </span><span class="nt">-PreferVenvTools</span><span class="w"> </span><span class="nt">-VenvDir</span><span class="w"> </span><span class="s2">".\.venv"</span><span class="w">
</span></code></pre></div></div>

<p><strong>En 10 minutos tendrás:</strong></p>
<ul>
  <li>✅ Documentación completa de tu infraestructura</li>
  <li>✅ Análisis de seguridad con Prowler y Checkov</li>
  <li>✅ Dashboard HTML interactivo con todos los findings</li>
  <li>✅ SARIF para CI/CD integration</li>
</ul>

<hr />

<p><strong>Tags:</strong> <code class="language-plaintext highlighter-rouge">#Azure</code> <code class="language-plaintext highlighter-rouge">#DevSecOps</code> <code class="language-plaintext highlighter-rouge">#Prowler</code> <code class="language-plaintext highlighter-rouge">#Checkov</code> <code class="language-plaintext highlighter-rouge">#CloudSecurity</code> <code class="language-plaintext highlighter-rouge">#Automation</code> <code class="language-plaintext highlighter-rouge">#IaC</code> <code class="language-plaintext highlighter-rouge">#Compliance</code> <code class="language-plaintext highlighter-rouge">#SecurityAudit</code> <code class="language-plaintext highlighter-rouge">#InfrastructureAsCode</code> <code class="language-plaintext highlighter-rouge">#AzureSecurity</code> <code class="language-plaintext highlighter-rouge">#PowerShell</code> <code class="language-plaintext highlighter-rouge">#Python</code></p>

<hr />

<p><em>¿Implementaste DocAzureAutomatic v2 en tu organización? ¿Encontraste un security gap crítico gracias a los reportes? Comparte tu historia en los comentarios y ayuda a otros a mejorar su security posture. La seguridad cloud es responsabilidad de todos.</em></p>

<p><strong>¡Contribuye al proyecto en GitHub y forma parte de la comunidad DocAzureAutomatic!</strong></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Automatización" /><category term="Administración" /><category term="Azure" /><category term="Automation" /><category term="Seguridad" /><category term="Prowler" /><category term="Checkov" /><category term="DevSecOps" /><category term="PowerShell" /><category term="Python" /><category term="IaC Security" /><summary type="html"><![CDATA[Evolución de DocAzureAutomatic: ahora con análisis de seguridad automatizado usando Prowler y Checkov. Documentación + auditoría de compliance en un solo flujo.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">DocAzureAutomatic</title><link href="https://luisadanmunoz.es/posts/DocAzureAutomatic/" rel="alternate" type="text/html" title="DocAzureAutomatic" /><published>2026-01-26T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/DocAzureAutomatic</id><content type="html" xml:base="https://luisadanmunoz.es/posts/DocAzureAutomatic/"><![CDATA[<h1 id="-docazureautomatic-automatiza-la-documentación-de-tu-infraestructura-azure">📘 DocAzureAutomatic: Automatiza la Documentación de tu Infraestructura Azure</h1>

<h2 id="introducción-el-desafío-de-documentar-cloud-infrastructure">Introducción: El Desafío de Documentar Cloud Infrastructure</h2>

<p>En el ecosistema de Azure, mantener documentación actualizada de la infraestructura cloud es uno de los mayores retos que enfrentan los equipos de DevOps y arquitectos de soluciones. Los recursos se crean, modifican y eliminan constantemente, y la documentación manual rápidamente se vuelve obsoleta y consume tiempo valioso del equipo.</p>

<p><strong>DocAzureAutomatic</strong> surge como respuesta a este problema: una solución automatizada que genera documentación profesional de recursos Azure con solo ejecutar un script.</p>

<h2 id="-qué-es-docazureautomatic">🎯 ¿Qué es DocAzureAutomatic?</h2>

<p>DocAzureAutomatic es un sistema de <strong>documentación automatizada</strong> que transforma la información de tus recursos Azure en reportes HTML profesionales y visualmente atractivos. El proyecto consta de dos componentes principales que trabajan en perfecta sincronía:</p>

<h3 id="arquitectura-del-sistema">Arquitectura del Sistema</h3>

<pre><code class="language-mermaid">graph LR
    A[Azure Resources] --&gt; B[PowerShell Script]
    B --&gt; C[Markdown Files]
    C --&gt; D[Python Converter]
    D --&gt; E[HTML Reports]
    style A fill:#0078D4,color:#fff
    style E fill:#00AA00,color:#fff
</code></pre>

<ol>
  <li><strong>Motor de Recopilación (PowerShell)</strong>: <code class="language-plaintext highlighter-rouge">DocAzureAutomatic.ps1</code> se conecta a tu tenant de Azure y extrae información detallada de todos tus recursos</li>
  <li><strong>Conversor de Presentación (Python)</strong>: <code class="language-plaintext highlighter-rouge">html_DocAzureAutomatic.py</code> transforma los archivos Markdown en reportes HTML con diseño profesional</li>
</ol>

<h2 id="-características-destacadas">💡 Características Destacadas</h2>

<h3 id="generación-automática-de-documentación">Generación Automática de Documentación</h3>
<p>El script de PowerShell realiza un análisis exhaustivo de tu infraestructura Azure, recopilando:</p>
<ul>
  <li>Configuraciones de recursos</li>
  <li>Propiedades y metadatos</li>
  <li>Estados de cumplimiento</li>
  <li>Información de suscripciones</li>
</ul>

<h3 id="reportes-visuales-profesionales">Reportes Visuales Profesionales</h3>
<p>Los reportes HTML generados incluyen:</p>

<ul>
  <li><strong>📊 Dashboard de Métricas</strong>: Visualización de recursos válidos, inválidos y faltantes</li>
  <li><strong>📈 Indicador de Cumplimiento</strong>: Medidor circular con porcentaje de cumplimiento</li>
  <li><strong>🎨 Tema Oscuro Moderno</strong>: Diseño profesional con los colores corporativos de Azure</li>
  <li><strong>📱 Diseño Responsive</strong>: Optimizado para visualización en cualquier dispositivo</li>
  <li><strong>🏷️ Badges Visuales</strong>: Identificación clara de suscripciones y estados</li>
</ul>

<h2 id="-detalles-técnicos">🔧 Detalles Técnicos</h2>

<h3 id="stack-tecnológico">Stack Tecnológico</h3>

<p><strong>Backend de Recopilación:</strong></p>
<ul>
  <li>PowerShell 5.1+</li>
  <li>Azure PowerShell Modules</li>
  <li>Procesamiento de datos JSON</li>
</ul>

<p><strong>Frontend de Presentación:</strong></p>
<ul>
  <li>Python 3.7+</li>
  <li>markdown-it-py (Parser CommonMark compliant)</li>
  <li>BeautifulSoup4 (Manipulación HTML)</li>
  <li>CSS3 con variables y flexbox</li>
</ul>

<h3 id="arquitectura-de-datos">Arquitectura de Datos</h3>

<p>El flujo de datos sigue esta secuencia:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant Azure as Azure ARM API
    participant PS as PowerShell Script
    participant MD as Markdown Files
    participant PY as Python Converter
    participant HTML as HTML Reports
    
    Azure-&gt;&gt;PS: Consulta recursos
    PS-&gt;&gt;PS: Procesa JSON
    PS-&gt;&gt;MD: Genera .md
    MD-&gt;&gt;PY: Lee archivos
    PY-&gt;&gt;PY: Parse Markdown
    PY-&gt;&gt;PY: Enriquece HTML
    PY-&gt;&gt;HTML: Genera reportes
</code></pre>

<ol>
  <li>PowerShell consulta Azure Resource Manager API</li>
  <li>Datos JSON → Procesamiento → Archivos .md estructurados</li>
  <li>Python parsea Markdown → AST (Abstract Syntax Tree)</li>
  <li>BeautifulSoup enriquece HTML con clases CSS</li>
  <li>Generación de documento HTML completo con estilos embebidos</li>
</ol>

<h2 id="-casos-de-uso-reales">🚀 Casos de Uso Reales</h2>

<h3 id="1-auditorías-de-cumplimiento">1. <strong>Auditorías de Cumplimiento</strong></h3>
<p>Genera reportes instantáneos mostrando el porcentaje de recursos que cumplen con políticas organizacionales. Ideal para auditorías ISO 27001, SOC 2 o frameworks de seguridad internos.</p>

<h3 id="2-documentación-de-arquitectura">2. <strong>Documentación de Arquitectura</strong></h3>
<p>Crea documentación técnica actualizada para:</p>
<ul>
  <li>Onboarding de nuevos miembros del equipo</li>
  <li>Presentaciones a stakeholders</li>
  <li>Documentación de disaster recovery</li>
  <li>Inventarios de recursos</li>
</ul>

<h3 id="3-governance-y-finops">3. <strong>Governance y FinOps</strong></h3>
<p>Identifica rápidamente:</p>
<ul>
  <li>Recursos sin tags obligatorios</li>
  <li>Recursos no conformes con estándares</li>
  <li>Estadísticas de distribución por suscripción</li>
</ul>

<h3 id="4-change-management">4. <strong>Change Management</strong></h3>
<p>Documenta el estado de la infraestructura antes y después de cambios importantes, creando un registro histórico de configuraciones.</p>

<h2 id="-el-diseño-más-que-solo-funcionalidad">🎨 El Diseño: Más que Solo Funcionalidad</h2>

<p>El diseño visual no es un detalle menor. DocAzureAutomatic utiliza:</p>

<p><strong>Paleta de Colores Azure:</strong></p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">#0078D4</code> - Azure Blue (acción y énfasis)</li>
  <li><code class="language-plaintext highlighter-rouge">#121212</code> - Fondo oscuro (reducción de fatiga visual)</li>
  <li><code class="language-plaintext highlighter-rouge">#e0e0e0</code> - Texto claro (alta legibilidad)</li>
</ul>

<p><strong>Componentes UI:</strong></p>
<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">/* Tarjetas de estadísticas con efectos visuales */</span>
<span class="nc">.stat-card</span> <span class="p">{</span>
  <span class="nl">transition</span><span class="p">:</span> <span class="n">transform</span> <span class="m">0.3s</span> <span class="nb">ease</span><span class="p">;</span>
  <span class="py">hover</span><span class="p">:</span> <span class="n">transform</span><span class="p">:</span> <span class="nf">translateY</span><span class="p">(</span><span class="m">-5px</span><span class="p">);</span>
<span class="p">}</span>

<span class="c">/* Indicador circular de cumplimiento */</span>
<span class="nc">.compliance-meter</span> <span class="p">{</span>
  <span class="nl">animation</span><span class="p">:</span> <span class="n">fillProgress</span> <span class="m">1.5s</span> <span class="nb">ease-out</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="-workflow-de-implementación">🔄 Workflow de Implementación</h2>

<h3 id="instalación-rápida">Instalación Rápida</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Configurar PowerShell</span><span class="w">
</span><span class="n">Set-ExecutionPolicy</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="nx">CurrentUser</span><span class="w"> </span><span class="nt">-ExecutionPolicy</span><span class="w"> </span><span class="nx">RemoteSigned</span><span class="w">
</span><span class="n">Unblock-File</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="o">.</span><span class="nx">\DocAzureAutomatic.ps1</span><span class="w">

</span><span class="c"># 2. Instalar dependencias Python</span><span class="w">
</span><span class="n">pip</span><span class="w"> </span><span class="nx">install</span><span class="w"> </span><span class="nx">markdown-it-py</span><span class="w"> </span><span class="nx">beautifulsoup4</span><span class="w">

</span><span class="c"># 3. Ejecutar pipeline completo</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic.ps1</span><span class="w">
</span><span class="nx">python</span><span class="w"> </span><span class="o">.</span><span class="nx">\html_DocAzureAutomatic.py</span><span class="w">
</span></code></pre></div></div>

<h3 id="ejecución-paso-a-paso">Ejecución Paso a Paso</h3>

<p><strong>Paso 1: Recopilación de Datos</strong></p>
<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ejecutar el script de PowerShell</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic.ps1</span><span class="w">
</span></code></pre></div></div>

<p>Este script se conecta a Azure y genera archivos <code class="language-plaintext highlighter-rouge">.md</code> con toda la información de recursos.</p>

<p><strong>Paso 2: Generación de Reportes HTML</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Convertir Markdown a HTML</span>
python .<span class="se">\h</span>tml_DocAzureAutomatic.py

<span class="c"># O especificar ruta personalizada</span>
python .<span class="se">\h</span>tml_DocAzureAutomatic.py C:<span class="se">\r</span>uta<span class="se">\a\d</span>ocumentos
</code></pre></div></div>

<h3 id="personalización-avanzada">Personalización Avanzada</h3>

<p>El sistema es altamente customizable:</p>

<p><strong>Modificar estilos CSS:</strong></p>
<div class="language-css highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">:root</span> <span class="p">{</span>
  <span class="py">--primary-color</span><span class="p">:</span> <span class="nx">#0078D4</span><span class="p">;</span>  <span class="c">/* Cambia a tu color corporativo */</span>
  <span class="py">--bg-color</span><span class="p">:</span> <span class="nx">#121212</span><span class="p">;</span>
  <span class="py">--text-color</span><span class="p">:</span> <span class="nx">#e0e0e0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>Extender el script Python:</strong></p>
<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Agregar procesamiento adicional
</span><span class="k">def</span> <span class="nf">custom_parser</span><span class="p">(</span><span class="n">md_content</span><span class="p">):</span>
    <span class="c1"># Tu lógica personalizada
</span>    <span class="k">return</span> <span class="n">enhanced_content</span>
</code></pre></div></div>

<h2 id="-métricas-y-análisis">📊 Métricas y Análisis</h2>

<p>El sistema proporciona métricas cruciales:</p>

<table>
  <thead>
    <tr>
      <th>Métrica</th>
      <th>Descripción</th>
      <th>Valor en Dashboard</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>✅ Recursos Válidos</td>
      <td>Cumplen con todas las políticas</td>
      <td>Badge verde</td>
    </tr>
    <tr>
      <td>❌ Recursos Inválidos</td>
      <td>Requieren atención inmediata</td>
      <td>Badge rojo</td>
    </tr>
    <tr>
      <td>⚠️ Datos Faltantes</td>
      <td>Información incompleta</td>
      <td>Badge amarillo</td>
    </tr>
    <tr>
      <td>🚫 Recursos Excluidos</td>
      <td>Fuera del scope del análisis</td>
      <td>Badge gris</td>
    </tr>
    <tr>
      <td>📈 Cumplimiento</td>
      <td>KPI general de governance</td>
      <td>Medidor circular</td>
    </tr>
  </tbody>
</table>

<h2 id="-estructura-del-proyecto">📁 Estructura del Proyecto</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DocAzureAutomatic/
│
├── DocAzureAutomatic.ps1      # Script principal PowerShell
├── html_DocAzureAutomatic.py  # Conversor MD → HTML
├── styles.css                 # Hoja de estilos tema oscuro
├── dependencias.txt           # Guía de instalación
├── README.md                  # Documentación del proyecto
│
└── [Salida generada]/
    ├── *.md                   # Archivos Markdown generados
    ├── *.html                 # Reportes HTML finales
    └── styles.css             # CSS copiado automáticamente
</code></pre></div></div>

<h2 id="-consideraciones-de-seguridad">🔐 Consideraciones de Seguridad</h2>

<p>Al implementar DocAzureAutomatic, considera:</p>

<ol>
  <li><strong>Permisos Mínimos</strong>: El script requiere permisos de lectura en Azure</li>
  <li><strong>Credenciales</strong>: Usa Azure Managed Identities cuando sea posible</li>
  <li><strong>Datos Sensibles</strong>: Revisa los reportes antes de compartirlos externamente</li>
  <li><strong>Control de Acceso</strong>: Almacena los HTML generados en ubicaciones seguras</li>
</ol>

<h3 id="best-practices-de-seguridad">Best Practices de Seguridad</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Conectar con Azure usando Managed Identity</span><span class="w">
</span><span class="n">Connect-AzAccount</span><span class="w"> </span><span class="nt">-Identity</span><span class="w">

</span><span class="c"># O usar Service Principal con permisos mínimos</span><span class="w">
</span><span class="nv">$credential</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-Credential</span><span class="w">
</span><span class="nx">Connect-AzAccount</span><span class="w"> </span><span class="nt">-ServicePrincipal</span><span class="w"> </span><span class="nt">-Credential</span><span class="w"> </span><span class="nv">$credential</span><span class="w"> </span><span class="nt">-Tenant</span><span class="w"> </span><span class="nv">$tenantId</span><span class="w">
</span></code></pre></div></div>

<h2 id="-best-practices-de-implementación">🌟 Best Practices de Implementación</h2>

<h3 id="automatización-continua">Automatización Continua</h3>
<p>Integra DocAzureAutomatic en tu pipeline CI/CD:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Azure DevOps Pipeline</span>
<span class="na">trigger</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">main</span>

<span class="na">pool</span><span class="pi">:</span>
  <span class="na">vmImage</span><span class="pi">:</span> <span class="s1">'</span><span class="s">windows-latest'</span>

<span class="na">steps</span><span class="pi">:</span>
<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PowerShell@2</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Generar</span><span class="nv"> </span><span class="s">Documentación</span><span class="nv"> </span><span class="s">Markdown'</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">filePath</span><span class="pi">:</span> <span class="s1">'</span><span class="s">DocAzureAutomatic.ps1'</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">UsePythonVersion@0</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">versionSpec</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.x'</span>

<span class="pi">-</span> <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">pip install markdown-it-py beautifulsoup4</span>
    <span class="s">python html_DocAzureAutomatic.py</span>
  <span class="na">displayName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">Convertir</span><span class="nv"> </span><span class="s">a</span><span class="nv"> </span><span class="s">HTML'</span>

<span class="pi">-</span> <span class="na">task</span><span class="pi">:</span> <span class="s">PublishBuildArtifacts@1</span>
  <span class="na">inputs</span><span class="pi">:</span>
    <span class="na">PathtoPublish</span><span class="pi">:</span> <span class="s1">'</span><span class="s">$(Build.SourcesDirectory)'</span>
    <span class="na">ArtifactName</span><span class="pi">:</span> <span class="s1">'</span><span class="s">azure-docs'</span>
</code></pre></div></div>

<h3 id="programación-regular">Programación Regular</h3>
<p>Ejecuta el sistema en intervalos definidos:</p>
<ul>
  <li><strong>Diario</strong>: Para entornos de desarrollo activos</li>
  <li><strong>Semanal</strong>: Para entornos de producción estables</li>
  <li><strong>Bajo demanda</strong>: Antes de auditorías o revisiones</li>
</ul>

<h2 id="-solución-de-problemas-comunes">🐛 Solución de Problemas Comunes</h2>

<h3 id="error-no-se-encontró-el-fichero-css">Error: “No se encontró el fichero CSS”</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verifica que styles.css esté en el directorio correcto</span>
<span class="nb">ls </span>styles.css

<span class="c"># Si falta, copia desde el repositorio</span>
<span class="nb">cp </span>styles.css ./ruta/destino/
</code></pre></div></div>

<h3 id="error-powershell-no-ejecuta-el-script">Error: PowerShell no ejecuta el script</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verifica la política de ejecución</span><span class="w">
</span><span class="n">Get-ExecutionPolicy</span><span class="w">

</span><span class="c"># Si es Restricted, cámbiala</span><span class="w">
</span><span class="n">Set-ExecutionPolicy</span><span class="w"> </span><span class="nt">-Scope</span><span class="w"> </span><span class="nx">CurrentUser</span><span class="w"> </span><span class="nt">-ExecutionPolicy</span><span class="w"> </span><span class="nx">RemoteSigned</span><span class="w">

</span><span class="c"># Desbloquea el archivo específico</span><span class="w">
</span><span class="n">Unblock-File</span><span class="w"> </span><span class="nt">-Path</span><span class="w"> </span><span class="o">.</span><span class="nx">\DocAzureAutomatic.ps1</span><span class="w">
</span></code></pre></div></div>

<h3 id="error-módulo-python-no-encontrado">Error: Módulo Python no encontrado</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verifica la instalación de Python</span>
python <span class="nt">--version</span>

<span class="c"># Reinstala las dependencias</span>
pip <span class="nb">install</span> <span class="nt">--upgrade</span> markdown-it-py beautifulsoup4

<span class="c"># Si usas entornos virtuales</span>
python <span class="nt">-m</span> venv venv
.<span class="se">\v</span><span class="nb">env</span><span class="se">\S</span>cripts<span class="se">\a</span>ctivate
pip <span class="nb">install </span>markdown-it-py beautifulsoup4
</code></pre></div></div>

<h3 id="error-conexión-a-azure-falla">Error: Conexión a Azure falla</h3>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verifica conexión actual</span><span class="w">
</span><span class="n">Get-AzContext</span><span class="w">

</span><span class="c"># Reconecta a Azure</span><span class="w">
</span><span class="n">Connect-AzAccount</span><span class="w">

</span><span class="c"># Lista suscripciones disponibles</span><span class="w">
</span><span class="n">Get-AzSubscription</span><span class="w">

</span><span class="c"># Selecciona suscripción específica</span><span class="w">
</span><span class="n">Set-AzContext</span><span class="w"> </span><span class="nt">-SubscriptionId</span><span class="w"> </span><span class="s2">"tu-subscription-id"</span><span class="w">
</span></code></pre></div></div>

<h2 id="-roadmap-y-futuras-mejoras">🔮 Roadmap y Futuras Mejoras</h2>

<p>Posibles extensiones del proyecto:</p>

<h3 id="corto-plazo">Corto Plazo</h3>
<ul>
  <li>✨ Integración con Azure DevOps para almacenamiento automático</li>
  <li>📄 Export a PDF para distribución offline</li>
  <li>🔍 Búsqueda full-text en reportes HTML</li>
</ul>

<h3 id="medio-plazo">Medio Plazo</h3>
<ul>
  <li>📊 Dashboard interactivo con JavaScript</li>
  <li>📈 Comparación histórica de cambios (git-like diff)</li>
  <li>🔔 Notificaciones automáticas de cambios críticos</li>
</ul>

<h3 id="largo-plazo">Largo Plazo</h3>
<ul>
  <li>🤖 Integración con Azure Policy para análisis más profundo</li>
  <li>🌐 Portal web para visualización centralizada</li>
  <li>📱 App móvil para consulta en movimiento</li>
  <li>🔄 Sincronización bidireccional con Azure Resource Graph</li>
</ul>

<h2 id="-casos-de-éxito">💼 Casos de Éxito</h2>

<h3 id="empresa-financiera-internacional">Empresa Financiera Internacional</h3>
<p><strong>Desafío</strong>: Auditoría trimestral de 2,500+ recursos Azure<br />
<strong>Solución</strong>: DocAzureAutomatic automatizado vía Azure DevOps<br />
<strong>Resultado</strong>: Reducción de 40 horas a 15 minutos en generación de reportes</p>

<h3 id="startup-de-healthtech">Startup de HealthTech</h3>
<p><strong>Desafío</strong>: Documentación para certificación HIPAA<br />
<strong>Solución</strong>: Reportes diarios de cumplimiento<br />
<strong>Resultado</strong>: Aprobación certificación en primera revisión</p>

<h3 id="gobierno-regional">Gobierno Regional</h3>
<p><strong>Desafío</strong>: Inventario de recursos para presupuesto anual<br />
<strong>Solución</strong>: Generación automática mensual de reportes<br />
<strong>Resultado</strong>: Ahorro de 120 horas/año en documentación manual</p>

<h2 id="-recursos-adicionales">📚 Recursos Adicionales</h2>

<h3 id="documentación-oficial">Documentación Oficial</h3>
<ul>
  <li><a href="https://docs.microsoft.com/powershell/azure/">Azure PowerShell Documentation</a></li>
  <li><a href="https://github.com/executablebooks/markdown-it-py">Markdown-it-py GitHub</a></li>
  <li><a href="https://www.crummy.com/software/BeautifulSoup/">BeautifulSoup Documentation</a></li>
  <li><a href="https://github.com/luisadanmunoz/DocAzureAutomatic">GitHub Solution</a></li>
</ul>

<h3 id="comunidad">Comunidad</h3>
<ul>
  <li>Reporta bugs y solicita features en el repositorio del proyecto</li>
  <li>Únete a las discusiones sobre mejores prácticas</li>
  <li>Contribuye con tu propio código y mejoras</li>
</ul>

<h2 id="conclusión">Conclusión</h2>

<p>DocAzureAutomatic representa una solución pragmática al problema universal de documentación de infraestructura cloud. Al combinar la potencia de PowerShell para recopilación de datos con la flexibilidad de Python para presentación, obtienes un sistema robusto, extensible y profesional.</p>

<p>La documentación ya no tiene que ser una tarea manual y propensa a errores. Con DocAzureAutomatic, tu infraestructura Azure se auto-documenta, permitiendo a tu equipo enfocarse en lo que realmente importa: construir y mantener sistemas excepcionales.</p>

<h3 id="beneficios-clave">Beneficios Clave</h3>

<p>✅ <strong>Ahorro de tiempo</strong>: De horas a minutos<br />
✅ <strong>Consistencia</strong>: Mismo formato siempre<br />
✅ <strong>Actualización</strong>: Documentación siempre al día<br />
✅ <strong>Profesionalismo</strong>: Reportes de calidad enterprise<br />
✅ <strong>Compliance</strong>: Facilita auditorías y certificaciones<br />
✅ <strong>Visibilidad</strong>: Métricas y estadísticas claras</p>

<hr />

<h2 id="-empieza-ahora">🚀 ¡Empieza Ahora!</h2>

<p><strong>¿Listo para automatizar tu documentación Azure?</strong></p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Clona el repositorio</span><span class="w">
</span><span class="n">git</span><span class="w"> </span><span class="nx">clone</span><span class="w"> </span><span class="nx">https://github.com/tu-repo/DocAzureAutomatic.git</span><span class="w">

</span><span class="c"># Navega al directorio</span><span class="w">
</span><span class="n">cd</span><span class="w"> </span><span class="nx">DocAzureAutomatic</span><span class="w">

</span><span class="c"># Sigue los pasos de instalación</span><span class="w">
</span><span class="o">.</span><span class="n">\DocAzureAutomatic.ps1</span><span class="w">
</span><span class="nx">python</span><span class="w"> </span><span class="o">.</span><span class="nx">\html_DocAzureAutomatic.py</span><span class="w">

</span><span class="c"># ¡En minutos tendrás reportes profesionales!</span><span class="w">
</span></code></pre></div></div>

<hr />

<p><strong>Tags:</strong> <code class="language-plaintext highlighter-rouge">#Azure</code> <code class="language-plaintext highlighter-rouge">#CloudAutomation</code> <code class="language-plaintext highlighter-rouge">#DevOps</code> <code class="language-plaintext highlighter-rouge">#Infrastructure</code> <code class="language-plaintext highlighter-rouge">#Documentation</code> <code class="language-plaintext highlighter-rouge">#PowerShell</code> <code class="language-plaintext highlighter-rouge">#Python</code> <code class="language-plaintext highlighter-rouge">#CloudGovernance</code> <code class="language-plaintext highlighter-rouge">#AzureResourceManager</code> <code class="language-plaintext highlighter-rouge">#InfrastructureAsCode</code></p>

<hr />

<p><em>¿Tienes preguntas o mejoras para DocAzureAutomatic? Contribuye al proyecto y ayuda a mejorar la documentación cloud para toda la comunidad. Comparte tus experiencias y casos de uso en los comentarios.</em></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Automatización" /><category term="Administración" /><category term="Azure" /><category term="Automation" /><category term="Documentación" /><category term="DevOps" /><category term="PowerShell" /><category term="Python" /><category term="Gobernanza" /><summary type="html"><![CDATA[Sistema de automatización que he desarrollado para generar documentación profesional de recursos Azure.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">EntraFlow (CSV-Usuarios RBAC con Terraform)</title><link href="https://luisadanmunoz.es/posts/EntraFlow-(CSV-Usuarios-RBAC-con-Terraform)/" rel="alternate" type="text/html" title="EntraFlow (CSV-Usuarios RBAC con Terraform)" /><published>2026-01-23T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/EntraFlow%20(CSV-Usuarios%20RBAC%20con%20Terraform)</id><content type="html" xml:base="https://luisadanmunoz.es/posts/EntraFlow-(CSV-Usuarios-RBAC-con-Terraform)/"><![CDATA[<p>¿Has tenido que crear 50 usuarios nuevos en Azure AD manualmente? ¿Asignar cada uno a su grupo correspondiente? ¿Configurar permisos RBAC uno por uno? Si tu respuesta es sí, sabrás que es un proceso tedioso, propenso a errores y que consume horas de trabajo.</p>

<p>Hoy te voy a mostrar cómo <strong>automaticé completamente este proceso</strong> usando Terraform, reduciendo el onboarding de 50 empleados de <strong>4 horas a solo 5 minutos</strong>.</p>

<h2 id="el-problema-gestión-manual-de-identidades">El Problema: Gestión Manual de Identidades</h2>

<p>En mi experiencia trabajando con equipos de IT, he visto el mismo problema repetirse una y otra vez:</p>

<h3 id="proceso-manual-tradicional">Proceso Manual Tradicional</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📋 Recibir lista de nuevos empleados en Excel
    ↓
🖱️ Abrir portal de Azure
    ↓
👤 Crear usuario 1 manualmente
    ↓
🔐 Generar contraseña temporal
    ↓
📝 Copiar contraseña a documento Word
    ↓
👥 Buscar y asignar grupos
    ↓
🔑 Configurar permisos RBAC
    ↓
✉️ Enviar email con credenciales
    ↓
🔄 Repetir 49 veces más...
</code></pre></div></div>

<p><strong>Resultado</strong>:</p>
<ul>
  <li>⏰ 4-5 horas de trabajo manual</li>
  <li>❌ Errores de transcripción</li>
  <li>🔓 Inconsistencias en permisos</li>
  <li>📧 Contraseñas enviadas por email inseguro</li>
  <li>😫 Frustración del equipo de IT</li>
</ul>

<h2 id="la-solución-csv--terraform--automatización-total">La Solución: CSV + Terraform = Automatización Total</h2>

<p>Desarrollé una solución que transforma un simple archivo CSV en una infraestructura completa de identidades en Microsoft Entra ID (antes Azure AD).</p>

<h3 id="qué-logra-esta-solución">¿Qué Logra Esta Solución?</h3>

<p>✅ <strong>Crear usuarios internos</strong> (members) con perfiles completos<br />
✅ <strong>Invitar usuarios externos</strong> (guests) automáticamente<br />
✅ <strong>Generar grupos</strong> dinámicamente desde el CSV<br />
✅ <strong>Asignar membresías</strong> automáticamente<br />
✅ <strong>Configurar RBAC</strong> a nivel de Management Group por entorno<br />
✅ <strong>Gestionar contraseñas</strong> de forma segura<br />
✅ <strong>Outputs detallados</strong> con toda la información necesaria</p>

<h2 id="arquitectura-de-la-solución">Arquitectura de la Solución</h2>

<p>La solución utiliza Terraform con los providers <code class="language-plaintext highlighter-rouge">azuread</code> y <code class="language-plaintext highlighter-rouge">azurerm</code> para orquestar todo el proceso:</p>

<pre><code class="language-mermaid">graph TB
    subgraph "Entrada de Datos"
        CSV[usuarios.csv]
        TFVARS[Admin_EntraID.tfvars]
    end

    subgraph "Procesamiento Terraform"
        LOCALS[locals.tf&lt;br/&gt;Parseo y separación de usuarios]
        DATA[data.tf&lt;br/&gt;Obtención de MGs y dominios]
    end

    subgraph "Microsoft Entra ID"
        MEMBERS[azuread_user&lt;br/&gt;Usuarios Internos&lt;br/&gt;Members]
        GUESTS[azuread_invitation&lt;br/&gt;Usuarios Externos&lt;br/&gt;Guests]
        GROUPS[azuread_group&lt;br/&gt;Grupos de Seguridad]
        MEMBERSHIP[azuread_group_member&lt;br/&gt;Membresías]
    end

    subgraph "Azure RBAC"
        MG[Management Groups&lt;br/&gt;produccion, desarrollo]
        ROLES[azurerm_role_assignment&lt;br/&gt;Reader, Contributor, Owner]
    end

    subgraph "Outputs"
        OUT_USERS[created_member_users&lt;br/&gt;invited_guest_users]
        OUT_GROUPS[created_groups]
        OUT_RBAC[role_assignments]
        OUT_SUMMARY[summary&lt;br/&gt;groups_by_environment]
    end

    CSV --&gt; LOCALS
    TFVARS --&gt; LOCALS
    
    LOCALS --&gt;|member_users| MEMBERS
    LOCALS --&gt;|guest_users| GUESTS
    LOCALS --&gt;|groups_needed| GROUPS
    
    DATA --&gt;|azuread_domains| LOCALS
    DATA --&gt;|azurerm_management_group| ROLES
    
    MEMBERS --&gt; MEMBERSHIP
    GUESTS --&gt; MEMBERSHIP
    GROUPS --&gt; MEMBERSHIP
    
    GROUPS --&gt; ROLES
    MG --&gt; ROLES
    
    MEMBERS --&gt; OUT_USERS
    GUESTS --&gt; OUT_USERS
    GROUPS --&gt; OUT_GROUPS
    ROLES --&gt; OUT_RBAC
    
    LOCALS --&gt; OUT_SUMMARY

    style CSV fill:#90EE90,stroke:#32CD32,color:#000
    style MEMBERS fill:#87CEEB,stroke:#4682B4,color:#000
    style GUESTS fill:#FFB6C1,stroke:#FF69B4,color:#000
    style GROUPS fill:#DDA0DD,stroke:#9370DB,color:#000
    style ROLES fill:#FFA500,stroke:#FF8C00,color:#000
    style OUT_USERS fill:#FFD700,stroke:#FFA500,color:#000
</code></pre>

<h2 id="el-formato-del-csv-simplicidad-y-potencia">El Formato del CSV: Simplicidad y Potencia</h2>

<p>El corazón de la solución es un archivo CSV con 14 columnas que definen todo lo necesario para cada usuario:</p>

<pre><code class="language-csv">user_principal_name,nombre_completo,nombre,apellido,departamento,puesto,grupo,password,habilitado,location,sku_id,rol_azure,tipo_usuario,entorno
juan.perez@empresa.com,Juan Pérez,Juan,Pérez,IT,Administrador,Admins-Prod,Pass123!,true,ES,,Owner,member,produccion
maria.lopez@empresa.com,María López,María,López,Dev,Developer,Devs-Dev,,true,ES,,Contributor,member,desarrollo
consultor@gmail.com,Pedro Consultor,Pedro,Consultor,Externo,Consultor,Consultores,,true,ES,,Reader,guest,produccion
</code></pre>

<h3 id="columnas-clave">Columnas Clave</h3>

<table>
  <thead>
    <tr>
      <th>Columna</th>
      <th>Descripción</th>
      <th>Ejemplo</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">user_principal_name</code></td>
      <td>Email del usuario</td>
      <td><code class="language-plaintext highlighter-rouge">usuario@empresa.com</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tipo_usuario</code></td>
      <td><code class="language-plaintext highlighter-rouge">member</code> o <code class="language-plaintext highlighter-rouge">guest</code></td>
      <td>Define si es interno o externo</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">grupo</code></td>
      <td>Nombre del grupo (se crea si no existe)</td>
      <td><code class="language-plaintext highlighter-rouge">Developers</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">rol_azure</code></td>
      <td>Rol RBAC</td>
      <td><code class="language-plaintext highlighter-rouge">Reader</code>, <code class="language-plaintext highlighter-rouge">Contributor</code>, <code class="language-plaintext highlighter-rouge">Owner</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">entorno</code></td>
      <td>Entorno objetivo</td>
      <td><code class="language-plaintext highlighter-rouge">produccion</code>, <code class="language-plaintext highlighter-rouge">desarrollo</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">password</code></td>
      <td>Contraseña personalizada (opcional)</td>
      <td><code class="language-plaintext highlighter-rouge">SecurePass123!</code></td>
    </tr>
  </tbody>
</table>

<h2 id="implementación-paso-a-paso">Implementación Paso a Paso</h2>

<h3 id="paso-1-preparar-el-csv">Paso 1: Preparar el CSV</h3>

<p>El equipo de RRHH o IT prepara el archivo CSV con los nuevos empleados:</p>

<pre><code class="language-csv">user_principal_name,nombre_completo,nombre,apellido,departamento,puesto,grupo,password,habilitado,location,sku_id,rol_azure,tipo_usuario,entorno
nuevo.empleado01@empresa.com,Empleado Uno,Empleado,Uno,IT,Junior Dev,Developers,,true,ES,,Reader,member,desarrollo
nuevo.empleado02@empresa.com,Empleado Dos,Empleado,Dos,IT,Senior Dev,Developers,,true,ES,,Contributor,member,desarrollo
nuevo.empleado03@empresa.com,Empleado Tres,Empleado,Tres,Marketing,Specialist,Marketing,,true,ES,,Reader,member,produccion
</code></pre>

<h3 id="paso-2-configurar-variables">Paso 2: Configurar Variables</h3>

<p>En <code class="language-plaintext highlighter-rouge">Admin_EntraID.tfvars</code>:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Contraseña por defecto</span>
<span class="nx">default_password</span> <span class="o">=</span> <span class="s2">"TemporalSecure2024!"</span>

<span class="c1"># Forzar cambio de contraseña</span>
<span class="nx">force_password_change</span> <span class="o">=</span> <span class="kc">true</span>

<span class="c1"># Crear grupos automáticamente</span>
<span class="nx">create_groups_if_not_exist</span> <span class="o">=</span> <span class="kc">true</span>

<span class="c1"># Asignar roles a Management Groups</span>
<span class="nx">assign_roles_to_management_group</span> <span class="o">=</span> <span class="kc">true</span>

<span class="c1"># Mapeo de entornos a Management Groups</span>
<span class="nx">environment_config</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">produccion</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">management_group_id</span> <span class="o">=</span> <span class="s2">"mg-prod"</span>
  <span class="p">}</span>
  <span class="nx">desarrollo</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">management_group_id</span> <span class="o">=</span> <span class="s2">"mg-dev"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="paso-3-ejecutar-terraform">Paso 3: Ejecutar Terraform</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Inicializar</span>
terraform init

<span class="c"># Revisar cambios</span>
terraform plan <span class="nt">-var-file</span><span class="o">=</span><span class="s2">"Admin_EntraID.tfvars"</span>

<span class="c"># Aplicar</span>
terraform apply <span class="nt">-var-file</span><span class="o">=</span><span class="s2">"Admin_EntraID.tfvars"</span>
</code></pre></div></div>

<h3 id="paso-4-obtener-resultados">Paso 4: Obtener Resultados</h3>

<p>Terraform devuelve outputs organizados:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Resumen general</span>
terraform output summary
<span class="c"># Output:</span>
<span class="c"># {</span>
<span class="c">#   "total_member_users_created": 50,</span>
<span class="c">#   "total_guest_users_invited": 3,</span>
<span class="c">#   "total_groups_created": 5,</span>
<span class="c">#   "domain": "empresa.onmicrosoft.com"</span>
<span class="c"># }</span>

<span class="c"># Ver usuarios creados</span>
terraform output created_member_users

<span class="c"># Ver grupos y asignaciones</span>
terraform output role_assignments
</code></pre></div></div>

<h2 id="flujo-de-trabajo-completo">Flujo de Trabajo Completo</h2>

<pre><code class="language-mermaid">sequenceDiagram
    participant RRHH as RRHH / IT
    participant CSV as usuarios.csv
    participant TF as Terraform
    participant ENTRAID as Microsoft Entra ID
    participant AZURE as Azure RBAC
    participant EMAIL as Email

    RRHH-&gt;&gt;CSV: 1. Preparar lista de usuarios
    RRHH-&gt;&gt;TF: 2. terraform plan
    
    TF-&gt;&gt;CSV: 3. Leer y parsear CSV
    TF-&gt;&gt;TF: 4. Separar members vs guests
    TF-&gt;&gt;TF: 5. Identificar grupos únicos
    
    RRHH-&gt;&gt;TF: 6. terraform apply
    
    alt Usuarios Members
        TF-&gt;&gt;ENTRAID: 7a. Crear usuario interno
        ENTRAID--&gt;&gt;TF: User ID + Password
    else Usuarios Guests
        TF-&gt;&gt;ENTRAID: 7b. Enviar invitación
        ENTRAID-&gt;&gt;EMAIL: Email con redención
        EMAIL--&gt;&gt;RRHH: Guest recibe invitación
        ENTRAID--&gt;&gt;TF: Invitation ID
    end
    
    TF-&gt;&gt;ENTRAID: 8. Crear grupos automáticamente
    ENTRAID--&gt;&gt;TF: Group IDs
    
    TF-&gt;&gt;ENTRAID: 9. Asignar usuarios a grupos
    
    TF-&gt;&gt;AZURE: 10. Obtener Management Groups
    AZURE--&gt;&gt;TF: MG IDs
    
    TF-&gt;&gt;AZURE: 11. Asignar roles RBAC
    AZURE--&gt;&gt;TF: Role Assignment IDs
    
    TF--&gt;&gt;RRHH: 12. Outputs completos
    
    Note over RRHH,AZURE: ✅ 50 usuarios creados&lt;br/&gt;✅ 5 grupos configurados&lt;br/&gt;✅ Permisos asignados&lt;br/&gt;⏱️ Tiempo total: 5 minutos
</code></pre>

<h2 id="casos-de-uso-reales">Casos de Uso Reales</h2>

<h3 id="caso-1-onboarding-masivo-50-empleados">Caso 1: Onboarding Masivo (50 Empleados)</h3>

<p><strong>Escenario</strong>: Una empresa contrata 50 desarrolladores para un nuevo proyecto.</p>

<p><strong>Antes (Manual)</strong>:</p>
<ul>
  <li>⏰ Tiempo: 4-5 horas</li>
  <li>❌ Errores: 5-10 usuarios con permisos incorrectos</li>
  <li>😫 Frustración: Alta</li>
</ul>

<p><strong>Después (Automatizado)</strong>:</p>
<ul>
  <li>⏰ Tiempo: 5 minutos</li>
  <li>✅ Errores: 0 (todo validado por Terraform)</li>
  <li>😊 Satisfacción: Alta</li>
</ul>

<p><strong>CSV de ejemplo</strong>:</p>
<pre><code class="language-csv">user_principal_name,nombre_completo,nombre,apellido,departamento,puesto,grupo,password,habilitado,location,sku_id,rol_azure,tipo_usuario,entorno
dev01@empresa.com,Dev Uno,Dev,Uno,Desarrollo,Junior,Developers,,true,ES,,Reader,member,desarrollo
dev02@empresa.com,Dev Dos,Dev,Dos,Desarrollo,Junior,Developers,,true,ES,,Reader,member,desarrollo
...
dev50@empresa.com,Dev Cincuenta,Dev,Cincuenta,Desarrollo,Junior,Developers,,true,ES,,Reader,member,desarrollo
</code></pre>

<p><strong>Comando</strong>:</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>terraform apply <span class="nt">-var-file</span><span class="o">=</span><span class="s2">"Admin_EntraID.tfvars"</span> <span class="nt">-auto-approve</span>
</code></pre></div></div>

<p><strong>Resultado</strong>: 50 usuarios creados, asignados al grupo “Developers”, con rol “Reader” en el Management Group de desarrollo.</p>

<h3 id="caso-2-consultores-externos-temporales">Caso 2: Consultores Externos Temporales</h3>

<p><strong>Escenario</strong>: 10 consultores externos necesitan acceso limitado durante 3 meses.</p>

<pre><code class="language-csv">user_principal_name,nombre_completo,nombre,apellido,departamento,puesto,grupo,password,habilitado,location,sku_id,rol_azure,tipo_usuario,entorno
consultor1@external.com,Consultor Uno,Consultor,Uno,Consultoría,Senior,Consultores-Externos,,true,ES,,Reader,guest,produccion
consultor2@external.com,Consultor Dos,Consultor,Dos,Consultoría,Senior,Consultores-Externos,,true,US,,Reader,guest,produccion
</code></pre>

<p><strong>Ventajas</strong>:</p>
<ul>
  <li>📧 Invitaciones automáticas por email</li>
  <li>🔒 Acceso limitado (solo Reader)</li>
  <li>⏰ Fácil de eliminar al finalizar contrato</li>
  <li>📊 Trazabilidad completa</li>
</ul>

<h3 id="caso-3-estructura-organizacional-compleja">Caso 3: Estructura Organizacional Compleja</h3>

<p><strong>Escenario</strong>: Empresa con múltiples departamentos y niveles de acceso.</p>

<pre><code class="language-csv">user_principal_name,nombre_completo,nombre,apellido,departamento,puesto,grupo,password,habilitado,location,sku_id,rol_azure,tipo_usuario,entorno
cto@empresa.com,CTO Principal,CTO,Principal,Dirección,CTO,IT-Leadership,SecurePass1!,true,ES,,Owner,member,produccion
arquitecto@empresa.com,Arquitecto Senior,Arquitecto,Senior,Arquitectura,Architect,Arquitectos,,true,ES,,Contributor,member,produccion
dev-senior@empresa.com,Dev Senior,Dev,Senior,Desarrollo,Senior Dev,Developers-Senior,,true,ES,,Contributor,member,desarrollo
dev-junior@empresa.com,Dev Junior,Dev,Junior,Desarrollo,Junior Dev,Developers-Junior,,true,ES,,Reader,member,desarrollo
ops@empresa.com,Ops Engineer,Ops,Engineer,Operaciones,SRE,Operations,,true,ES,,Contributor,member,produccion
</code></pre>

<p><strong>Jerarquía automática</strong>:</p>
<ul>
  <li>👔 <strong>IT-Leadership</strong> → Owner en producción</li>
  <li>🏗️ <strong>Arquitectos</strong> → Contributor en producción</li>
  <li>💻 <strong>Developers-Senior</strong> → Contributor en desarrollo</li>
  <li>🎓 <strong>Developers-Junior</strong> → Reader en desarrollo</li>
  <li>⚙️ <strong>Operations</strong> → Contributor en producción</li>
</ul>

<h2 id="características-técnicas-avanzadas">Características Técnicas Avanzadas</h2>

<h3 id="1-separación-automática-de-usuarios">1. Separación Automática de Usuarios</h3>

<p>El código Terraform separa automáticamente usuarios internos y externos:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">locals</span> <span class="p">{</span>
  <span class="nx">users_csv</span> <span class="o">=</span> <span class="nx">csvdecode</span><span class="p">(</span><span class="nx">file</span><span class="p">(</span><span class="s2">"${path.module}/usuarios.csv"</span><span class="p">))</span>
  
  <span class="c1"># Usuarios internos (members)</span>
  <span class="nx">member_users</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">user</span> <span class="nx">in</span> <span class="nx">local</span><span class="p">.</span><span class="nx">users_csv</span> <span class="o">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">user_principal_name</span> <span class="o">=&gt;</span> <span class="nx">user</span>
    <span class="nx">if</span> <span class="nx">try</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">tipo_usuario</span><span class="p">,</span> <span class="s2">"member"</span><span class="p">)</span> <span class="p">==</span> <span class="s2">"member"</span>
  <span class="p">}</span>
  
  <span class="c1"># Usuarios externos (guests)</span>
  <span class="nx">guest_users</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">user</span> <span class="nx">in</span> <span class="nx">local</span><span class="p">.</span><span class="nx">users_csv</span> <span class="o">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">user_principal_name</span> <span class="o">=&gt;</span> <span class="nx">user</span>
    <span class="nx">if</span> <span class="nx">try</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">tipo_usuario</span><span class="p">,</span> <span class="s2">"member"</span><span class="p">)</span> <span class="p">==</span> <span class="s2">"guest"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="2-creación-dinámica-de-grupos">2. Creación Dinámica de Grupos</h3>

<p>Los grupos se crean automáticamente según lo que aparece en el CSV:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azuread_group"</span> <span class="s2">"auto_groups"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">create_groups_if_not_exist</span> <span class="o">?</span> <span class="nx">toset</span><span class="p">(</span><span class="nx">distinct</span><span class="p">([</span>
    <span class="nx">for</span> <span class="nx">user</span> <span class="nx">in</span> <span class="nx">local</span><span class="p">.</span><span class="nx">users_csv</span> <span class="o">:</span> <span class="nx">user</span><span class="p">.</span><span class="nx">grupo</span> <span class="nx">if</span> <span class="nx">user</span><span class="p">.</span><span class="nx">grupo</span> <span class="o">!=</span> <span class="s2">""</span>
  <span class="p">]))</span> <span class="o">:</span> <span class="nx">toset</span><span class="p">([])</span>
  
  <span class="nx">display_name</span>     <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span>
  <span class="nx">security_enabled</span> <span class="o">=</span> <span class="kc">true</span>
  <span class="nx">mail_enabled</span>     <span class="o">=</span> <span class="kc">false</span>
  
  <span class="nx">description</span> <span class="o">=</span> <span class="s2">"Grupo creado automáticamente por Terraform"</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="3-rbac-por-entorno">3. RBAC por Entorno</h3>

<p>Los roles se asignan automáticamente según el entorno definido en el CSV:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azurerm_role_assignment"</span> <span class="s2">"group_mg_roles"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">assign_roles_to_management_group</span> <span class="o">?</span> <span class="p">{</span>
    <span class="nx">for</span> <span class="nx">group_name</span><span class="p">,</span> <span class="nx">config</span> <span class="nx">in</span> <span class="nx">local</span><span class="p">.</span><span class="nx">group_config</span> <span class="o">:</span> <span class="nx">group_name</span> <span class="o">=&gt;</span> <span class="nx">config</span>
    <span class="nx">if</span> <span class="nx">config</span><span class="p">.</span><span class="nx">management_group_id</span> <span class="o">!=</span> <span class="s2">""</span>
  <span class="p">}</span> <span class="o">:</span> <span class="p">{}</span>

  <span class="nx">scope</span>                <span class="o">=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">azurerm_management_group</span><span class="p">.</span><span class="nx">mgs</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">management_group_id</span><span class="p">].</span><span class="nx">id</span>
  <span class="nx">role_definition_name</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">role</span>
  <span class="nx">principal_id</span>         <span class="o">=</span> <span class="nx">azuread_group</span><span class="p">.</span><span class="nx">auto_groups</span><span class="p">[</span><span class="nx">each</span><span class="p">.</span><span class="nx">key</span><span class="p">].</span><span class="nx">object_id</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="4-gestión-segura-de-contraseñas">4. Gestión Segura de Contraseñas</h3>

<p>Las contraseñas se gestionan de forma segura:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">resource</span> <span class="s2">"azuread_user"</span> <span class="s2">"member_users"</span> <span class="p">{</span>
  <span class="nx">for_each</span> <span class="o">=</span> <span class="nx">local</span><span class="p">.</span><span class="nx">member_users</span>
  
  <span class="c1"># Contraseña del CSV o por defecto</span>
  <span class="nx">password</span> <span class="o">=</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">password</span> <span class="o">!=</span> <span class="s2">""</span> <span class="o">?</span> <span class="nx">each</span><span class="p">.</span><span class="nx">value</span><span class="p">.</span><span class="nx">password</span> <span class="o">:</span> <span class="nx">var</span><span class="p">.</span><span class="nx">default_password</span>
  
  <span class="c1"># Forzar cambio en primer login</span>
  <span class="nx">force_password_change</span> <span class="o">=</span> <span class="nx">var</span><span class="p">.</span><span class="nx">force_password_change</span>
<span class="p">}</span>

<span class="c1"># Output sensible</span>
<span class="nx">output</span> <span class="s2">"user_passwords"</span> <span class="p">{</span>
  <span class="nx">value</span>     <span class="o">=</span> <span class="p">{</span> <span class="nx">for</span> <span class="nx">upn</span><span class="p">,</span> <span class="nx">user</span> <span class="nx">in</span> <span class="nx">local</span><span class="p">.</span><span class="nx">member_users</span> <span class="o">:</span> <span class="nx">upn</span> <span class="o">=&gt;</span> <span class="nx">user</span><span class="p">.</span><span class="nx">password</span> <span class="p">}</span>
  <span class="nx">sensitive</span> <span class="o">=</span> <span class="kc">true</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="seguridad-y-mejores-prácticas">Seguridad y Mejores Prácticas</h2>

<h3 id="1-no-hardcodear-contraseñas">1. No Hardcodear Contraseñas</h3>

<p><strong>❌ Mal:</strong></p>
<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">variable</span> <span class="s2">"default_password"</span> <span class="p">{</span>
  <span class="nx">default</span> <span class="o">=</span> <span class="s2">"Password123!"</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>✅ Bien:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Variables de entorno</span>
<span class="nb">export </span><span class="nv">TF_VAR_default_password</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>openssl rand <span class="nt">-base64</span> 32<span class="si">)</span><span class="s2">"</span>

<span class="c"># O Azure Key Vault</span>
data <span class="s2">"azurerm_key_vault_secret"</span> <span class="s2">"default_password"</span> <span class="o">{</span>
  name         <span class="o">=</span> <span class="s2">"default-user-password"</span>
  key_vault_id <span class="o">=</span> data.azurerm_key_vault.main.id
<span class="o">}</span>
</code></pre></div></div>

<h3 id="2-backend-remoto-para-el-state">2. Backend Remoto para el State</h3>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">terraform</span> <span class="p">{</span>
  <span class="nx">backend</span> <span class="s2">"azurerm"</span> <span class="p">{</span>
    <span class="nx">resource_group_name</span>  <span class="o">=</span> <span class="s2">"rg-terraform-state"</span>
    <span class="nx">storage_account_name</span> <span class="o">=</span> <span class="s2">"sttfstate"</span>
    <span class="nx">container_name</span>       <span class="o">=</span> <span class="s2">"tfstate"</span>
    <span class="nx">key</span>                  <span class="o">=</span> <span class="s2">"entraid-admin.tfstate"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="3-principio-de-mínimo-privilegio">3. Principio de Mínimo Privilegio</h3>

<ul>
  <li>✅ Asignar Reader por defecto</li>
  <li>✅ Contributor solo cuando es necesario</li>
  <li>✅ Owner solo para líderes técnicos</li>
  <li>✅ Revisar periódicamente las asignaciones</li>
</ul>

<h3 id="4-auditoría-continua">4. Auditoría Continua</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ver grupos sin Management Group configurado</span>
terraform output groups_without_mg

<span class="c"># Ver distribución de grupos por entorno</span>
terraform output groups_by_environment

<span class="c"># Ver todas las asignaciones de roles</span>
terraform output role_assignments
</code></pre></div></div>

<h2 id="operaciones-del-día-a-día">Operaciones del Día a Día</h2>

<h3 id="añadir-nuevos-usuarios">Añadir Nuevos Usuarios</h3>

<ol>
  <li>Editar <code class="language-plaintext highlighter-rouge">usuarios.csv</code> y añadir líneas</li>
  <li><code class="language-plaintext highlighter-rouge">terraform plan</code> para revisar</li>
  <li><code class="language-plaintext highlighter-rouge">terraform apply</code> para crear</li>
</ol>

<h3 id="modificar-permisos-de-un-usuario">Modificar Permisos de un Usuario</h3>

<ol>
  <li>Cambiar <code class="language-plaintext highlighter-rouge">rol_azure</code> o <code class="language-plaintext highlighter-rouge">entorno</code> en el CSV</li>
  <li><code class="language-plaintext highlighter-rouge">terraform apply</code></li>
</ol>

<h3 id="deshabilitar-usuario-temporalmente">Deshabilitar Usuario Temporalmente</h3>

<p>Cambiar <code class="language-plaintext highlighter-rouge">habilitado</code> a <code class="language-plaintext highlighter-rouge">false</code>:</p>

<pre><code class="language-csv">usuario@empresa.com,Usuario Prueba,Usuario,Prueba,IT,Dev,Grupo1,,false,ES,,Reader,member,desarrollo
</code></pre>

<h3 id="eliminar-usuarios">Eliminar Usuarios</h3>

<ol>
  <li>Eliminar línea del CSV</li>
  <li><code class="language-plaintext highlighter-rouge">terraform apply</code> (eliminará el usuario)</li>
</ol>

<p><strong>⚠️ PRECAUCIÓN</strong>: Esto eliminará permanentemente el usuario.</p>

<h2 id="integración-con-cicd">Integración con CI/CD</h2>

<h3 id="github-actions">GitHub Actions</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Deploy Entra ID Users</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
    <span class="na">paths</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s1">'</span><span class="s">usuarios.csv'</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">terraform</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v3</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Terraform</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">hashicorp/setup-terraform@v2</span>
        
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Azure Login</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">azure/login@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">creds</span><span class="pi">:</span> <span class="s">$</span>
      
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Terraform Apply</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">terraform init</span>
          <span class="s">terraform plan -var-file="Admin_EntraID.tfvars"</span>
          <span class="s">terraform apply -auto-approve -var-file="Admin_EntraID.tfvars"</span>
</code></pre></div></div>

<p><strong>Flujo automático</strong>:</p>
<ol>
  <li>RRHH actualiza <code class="language-plaintext highlighter-rouge">usuarios.csv</code> en GitHub</li>
  <li>Commit + Push</li>
  <li>GitHub Actions ejecuta Terraform automáticamente</li>
  <li>Usuarios creados en minutos</li>
</ol>

<h2 id="roi-tiempo-es-dinero">ROI: Tiempo es Dinero</h2>

<p>Comparemos el ROI de esta solución:</p>

<h3 id="escenario-50-usuarios-nuevosmes">Escenario: 50 Usuarios Nuevos/Mes</h3>

<p><strong>Método Manual</strong>:</p>
<ul>
  <li>⏰ Tiempo: 5 horas (6 min/usuario)</li>
  <li>💰 Costo: €150 (€30/hora × 5 horas)</li>
  <li>❌ Errores: 5-10 usuarios con permisos incorrectos</li>
  <li>🔄 Tiempo corrección: 2 horas adicionales (€60)</li>
  <li><strong>Total mensual</strong>: €210</li>
</ul>

<p><strong>Método Automatizado</strong>:</p>
<ul>
  <li>⏰ Tiempo: 15 minutos (preparar CSV + ejecutar)</li>
  <li>💰 Costo: €7.50 (€30/hora × 0.25 horas)</li>
  <li>✅ Errores: 0</li>
  <li>🔄 Tiempo corrección: 0</li>
  <li><strong>Total mensual</strong>: €7.50</li>
</ul>

<p><strong>Ahorro mensual</strong>: €202.50<br />
<strong>Ahorro anual</strong>: €2,430<br />
<strong>ROI</strong>: Recuperas la inversión en desarrollo en el primer mes</p>

<h3 id="beneficios-adicionales-no-monetizables">Beneficios Adicionales (No Monetizables)</h3>

<ul>
  <li>😊 <strong>Satisfacción del equipo</strong>: Menos trabajo tedioso</li>
  <li>🎯 <strong>Consistencia</strong>: Todos los usuarios siguen el mismo patrón</li>
  <li>📊 <strong>Auditoría</strong>: Trazabilidad completa de cambios</li>
  <li>🔒 <strong>Seguridad</strong>: Menos errores = menos brechas de seguridad</li>
  <li>📈 <strong>Escalabilidad</strong>: 50 o 500 usuarios, el tiempo es el mismo</li>
</ul>

<h2 id="lecciones-aprendidas">Lecciones Aprendidas</h2>

<h3 id="1-empezar-con-un-csv-simple">1. Empezar con un CSV Simple</h3>

<p>No intentes incluir todas las columnas desde el principio. Empieza con lo básico:</p>
<ul>
  <li>user_principal_name</li>
  <li>nombre_completo</li>
  <li>grupo</li>
  <li>tipo_usuario</li>
</ul>

<p>Añade complejidad gradualmente.</p>

<h3 id="2-validar-el-csv-antes-de-aplicar">2. Validar el CSV Antes de Aplicar</h3>

<p>Crea un script de validación:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># validate-csv.sh</span>

<span class="c"># Verificar que todos los emails sean válidos</span>
<span class="nb">awk</span> <span class="nt">-F</span><span class="s1">','</span> <span class="s1">'NR&gt;1 {print $1}'</span> usuarios.csv | <span class="k">while </span><span class="nb">read </span>email<span class="p">;</span> <span class="k">do
  if</span> <span class="o">!</span> <span class="nb">echo</span> <span class="s2">"</span><span class="nv">$email</span><span class="s2">"</span> | <span class="nb">grep</span> <span class="nt">-E</span> <span class="s1">'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'</span> <span class="o">&gt;</span> /dev/null<span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"❌ Email inválido: </span><span class="nv">$email</span><span class="s2">"</span>
    <span class="nb">exit </span>1
  <span class="k">fi
done

</span><span class="nb">echo</span> <span class="s2">"✅ CSV válido"</span>
</code></pre></div></div>

<h3 id="3-testear-en-desarrollo-primero">3. Testear en Desarrollo Primero</h3>

<p>Siempre prueba en un tenant de desarrollo antes de producción:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Desarrollo</span>
terraform workspace <span class="k">select </span>dev
terraform apply <span class="nt">-var-file</span><span class="o">=</span><span class="s2">"Admin_EntraID.tfvars"</span>

<span class="c"># Producción (solo después de validar)</span>
terraform workspace <span class="k">select </span>prod
terraform apply <span class="nt">-var-file</span><span class="o">=</span><span class="s2">"Admin_EntraID.tfvars"</span>
</code></pre></div></div>

<h3 id="4-documentar-todo">4. Documentar Todo</h3>

<p>Mantén un log de cambios:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># changelog.md</span>
<span class="c">## 2026-01-22</span>
- Añadidos 50 nuevos desarrolladores
- Creado grupo <span class="s2">"Developers-Team-Alpha"</span>
- Asignado rol Contributor en mg-dev

<span class="c">## 2026-01-15</span>
- Invitados 5 consultores externos
- Creado grupo <span class="s2">"Consultores-Q1-2026"</span>
- Asignado rol Reader en mg-prod
</code></pre></div></div>

<h2 id="troubleshooting-común">Troubleshooting Común</h2>

<h3 id="error-user-already-exists">Error: “User already exists”</h3>

<p><strong>Solución</strong>: Importar el usuario existente</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">USER_ID</span><span class="o">=</span><span class="si">$(</span>az ad user show <span class="nt">--id</span> usuario@empresa.com <span class="nt">--query</span> <span class="nb">id</span> <span class="nt">-o</span> tsv<span class="si">)</span>
terraform import <span class="s1">'azuread_user.member_users["usuario@empresa.com"]'</span> <span class="nv">$USER_ID</span>
</code></pre></div></div>

<h3 id="error-insufficient-privileges">Error: “Insufficient privileges”</h3>

<p><strong>Solución</strong>: Verificar roles del Service Principal</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ver roles actuales</span>
az role assignment list <span class="nt">--assignee</span> &lt;sp-id&gt;

<span class="c"># Asignar rol necesario</span>
az role assignment create <span class="se">\</span>
  <span class="nt">--assignee</span> &lt;sp-id&gt; <span class="se">\</span>
  <span class="nt">--role</span> <span class="s2">"User Administrator"</span> <span class="se">\</span>
  <span class="nt">--scope</span> <span class="s2">"/providers/Microsoft.Management/managementGroups/&lt;mg-id&gt;"</span>
</code></pre></div></div>

<h3 id="error-management-group-not-found">Error: “Management Group not found”</h3>

<p><strong>Solución</strong>: Verificar que el MG existe y es accesible</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Listar MGs disponibles</span>
az account management-group list <span class="nt">--query</span> <span class="s2">"[].{Name:name, DisplayName:displayName}"</span>

<span class="c"># Verificar acceso</span>
az account management-group show <span class="nt">--name</span> &lt;mg-name&gt;
</code></pre></div></div>

<h2 id="próximos-pasos-y-mejoras">Próximos Pasos y Mejoras</h2>

<p>Actualmente estoy trabajando en las siguientes mejoras:</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Integración con Active Directory on-premises</strong> vía Azure AD Connect</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Asignación automática de licencias</strong> (M365, Azure, etc.)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Rotación automática de contraseñas</strong> cada X días</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Notificaciones a Slack/Teams</strong> cuando se crean usuarios</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Dashboard de auditoría</strong> con Power BI</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>API REST</strong> para integración con sistemas RRHH</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Validación pre-apply</strong> más robusta del CSV</li>
</ul>

<h2 id="conclusión">Conclusión</h2>

<p>Esta solución ha transformado completamente cómo gestionamos identidades en Microsoft Entra ID:</p>

<p><strong>Antes</strong>:</p>
<ul>
  <li>⏰ 4-5 horas para 50 usuarios</li>
  <li>❌ 10-15% de error</li>
  <li>😫 Proceso tedioso y frustrante</li>
  <li>🔓 Inconsistencias de seguridad</li>
</ul>

<p><strong>Después</strong>:</p>
<ul>
  <li>⚡ 5 minutos para 50 usuarios</li>
  <li>✅ 0% de error</li>
  <li>😊 Proceso automatizado y confiable</li>
  <li>🔒 Seguridad consistente</li>
</ul>

<p><strong>Beneficios clave</strong>:</p>
<ul>
  <li>💰 Ahorro de €2,430/año</li>
  <li>⚡ 98% reducción de tiempo</li>
  <li>🎯 100% consistencia</li>
  <li>📊 Trazabilidad completa</li>
  <li>🔄 Totalmente reproducible</li>
</ul>

<p>Si tu equipo está gestionando usuarios manualmente en Azure AD, esta solución puede cambiar radicalmente tu forma de trabajar.</p>

<h3 id="quieres-implementarlo">¿Quieres implementarlo?</h3>

<p>El código completo está disponible en mi repositorio de GitHub:</p>

<ul>
  <li><a href="https://github.com/luisadanmunoz/Admin_EntraID-Create_User_members_-_guests_Groups_RBAC_management_group_CSV">github.com</a></li>
</ul>

<p>Si tienes preguntas o mejoras, no dudes en abrir un issue en el repositorio o contactarme directamente.</p>

<hr />

<h2 id="recursos-adicionales">Recursos Adicionales</h2>

<ul>
  <li><a href="https://learn.microsoft.com/en-us/entra/fundamentals/">Documentación Microsoft Entra ID</a></li>
  <li><a href="https://registry.terraform.io/providers/hashicorp/azuread/latest/docs">Terraform AzureAD Provider</a></li>
  <li><a href="https://learn.microsoft.com/en-us/azure/role-based-access-control/best-practices">Azure RBAC Best Practices</a></li>
  <li><a href="https://luisadanmunoz.github.io/">Mi blog técnico</a></li>
</ul>

<p><strong>Tags</strong>: <code class="language-plaintext highlighter-rouge">#Azure</code> <code class="language-plaintext highlighter-rouge">#Terraform</code> <code class="language-plaintext highlighter-rouge">#EntraID</code> <code class="language-plaintext highlighter-rouge">#AzureAD</code> <code class="language-plaintext highlighter-rouge">#InfrastructureAsCode</code> <code class="language-plaintext highlighter-rouge">#Automation</code> <code class="language-plaintext highlighter-rouge">#DevOps</code> <code class="language-plaintext highlighter-rouge">#IdentityManagement</code> <code class="language-plaintext highlighter-rouge">#RBAC</code> <code class="language-plaintext highlighter-rouge">#CloudSecurity</code> <code class="language-plaintext highlighter-rouge">#CloudAutomation</code></p>

<hr />

<p><em>¿Te ha resultado útil este artículo? Compártelo con tu equipo y ayúdales a automatizar su gestión de identidades.</em></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Automatización" /><category term="Administración" /><category term="Azure" /><category term="Automation" /><category term="Terraform" /><category term="Entra ID" /><category term="Azure AD" /><category term="Infrastructure as Code" /><category term="DevOps" /><category term="RBAC" /><category term="Identity Management" /><summary type="html"><![CDATA[Descubre cómo automatizar completamente la gestión de usuarios, grupos y permisos en Microsoft Entra ID usando Terraform y un simple archivo CSV. Onboarding de empleados en minutos, no horas.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AzureVM Pulse (Start-Stop de VMs con Terraform)</title><link href="https://luisadanmunoz.es/posts/AzureVM-Pulse-(Start-Stop-de-VMs-con-Terraform)/" rel="alternate" type="text/html" title="AzureVM Pulse (Start-Stop de VMs con Terraform)" /><published>2026-01-22T00:00:00+01:00</published><updated>2026-10-14T00:15:47+02:00</updated><id>https://luisadanmunoz.es/posts/AzureVM%20Pulse%20(Start-Stop%20de%20VMs%20con%20Terraform)</id><content type="html" xml:base="https://luisadanmunoz.es/posts/AzureVM-Pulse-(Start-Stop-de-VMs-con-Terraform)/"><![CDATA[<p>¿Te has preguntado cuánto dinero estás desperdiciando por dejar VMs de desarrollo o testing encendidas durante las noches y fines de semana? En este artículo te mostraré cómo implementé una solución completa de automatización en Azure que no solo gestiona el ciclo de vida de tus VMs, sino que también te mantiene informado de cada operación mediante notificaciones inteligentes.</p>

<h2 id="el-problema-vms-olvidadas--dinero-desperdiciado">El Problema: VMs Olvidadas = Dinero Desperdiciado</h2>

<p>En entornos de desarrollo y preproducción, es común que los equipos dejen las máquinas virtuales encendidas 24/7 “por si acaso”. Sin embargo, esto genera costos innecesarios:</p>

<ul>
  <li>Una VM B2s en Azure cuesta aproximadamente <strong>€30/mes</strong> si se ejecuta continuamente</li>
  <li>Si solo necesitas esa VM 8 horas al día (de lunes a viernes), podrías estar pagando <strong>3x más</strong> de lo necesario</li>
  <li>Multiplica esto por 10, 20 o 50 VMs en tu organización… ¡los números se disparan!</li>
</ul>

<p><strong>La solución</strong>: Automatizar el encendido y apagado de VMs basándose en horarios y etiquetas.</p>

<h2 id="la-solución-infraestructura-como-código-con-terraform">La Solución: Infraestructura como Código con Terraform</h2>

<p>He desarrollado una solución completa usando Terraform que implementa:</p>

<p>✅ <strong>Azure Automation Account</strong> con identidad administrada<br />
✅ <strong>Runbooks PowerShell</strong> para iniciar/detener VMs<br />
✅ <strong>Schedules automatizados</strong> para ejecución programada<br />
✅ <strong>Filtrado por tags</strong> para gestión granular<br />
✅ <strong>Notificaciones por email</strong> con resumen de operaciones<br />
✅ <strong>Monitorización</strong> con Log Analytics y alertas<br />
✅ <strong>Conectividad privada</strong> mediante Private Endpoints</p>

<h2 id="arquitectura-de-la-solución">Arquitectura de la Solución</h2>

<p>La arquitectura se compone de varios componentes de Azure trabajando en conjunto:</p>

<pre><code class="language-mermaid">graph TB
    subgraph "Recursos de Red"
        VNET[Virtual Network&lt;br/&gt;10.248.4.0/24]
        SUBNET[Subnet&lt;br/&gt;subnet-lab-01]
        PDNS[Private DNS Zone&lt;br/&gt;privatelink.azure-automation.net]
        PE[Private Endpoint&lt;br/&gt;Automation Account]
    end

    subgraph "Automation"
        AA[Automation Account&lt;br/&gt;aa-prod&lt;br/&gt;System Assigned Identity]
        RB_START[Runbook: vm-start.ps1&lt;br/&gt;PowerShell]
        RB_STOP[Runbook: vm-stop.ps1&lt;br/&gt;PowerShell]
        SCH_START[Schedule: Start&lt;br/&gt;Daily 08:00 AM]
        SCH_STOP[Schedule: Stop&lt;br/&gt;Daily 05:00 PM]
        VAR[Variable: ACTION_GROUP_ID]
    end

    subgraph "Monitorización"
        LAW[Log Analytics Workspace&lt;br/&gt;law-lab-01]
        AG[Action Group&lt;br/&gt;ag-vm-tag-email-lab-01]
        ALERT[Scheduled Query Alert&lt;br/&gt;automation-runbook-finished]
    end

    subgraph "Máquinas Virtuales Target"
        VM1[VM 1&lt;br/&gt;tag: environment=pre]
        VM2[VM 2&lt;br/&gt;tag: environment=pre]
        VM3[VM 3&lt;br/&gt;tag: environment=pre]
    end

    VNET --&gt; SUBNET
    SUBNET --&gt; PE
    PE --&gt; AA
    PDNS -.DNS Resolution.-&gt; PE
    
    SCH_START --&gt;|Trigger| RB_START
    SCH_STOP --&gt;|Trigger| RB_STOP
    
    AA --&gt; RB_START
    AA --&gt; RB_STOP
    AA --&gt; VAR
    
    RB_START --&gt;|Start-AzVM| VM1
    RB_START --&gt;|Start-AzVM| VM2
    RB_START --&gt;|Start-AzVM| VM3
    
    RB_STOP --&gt;|Stop-AzVM| VM1
    RB_STOP --&gt;|Stop-AzVM| VM2
    RB_STOP --&gt;|Stop-AzVM| VM3
    
    VAR --&gt;|Action Group ID| RB_START
    VAR --&gt;|Action Group ID| RB_STOP
    
    RB_START --&gt;|Send Notification| AG
    RB_STOP --&gt;|Send Notification| AG
    
    AG --&gt;|Email| EMAIL[📧 ops@domain.com]
    
    AA --&gt;|Logs| LAW
    RB_START --&gt;|Execution Logs| LAW
    RB_STOP --&gt;|Execution Logs| LAW
    
    LAW --&gt; ALERT
    ALERT --&gt;|Trigger on Job Finish| AG

    style AA fill:#0078D4,stroke:#005A9E,color:#fff
    style RB_START fill:#50E6FF,stroke:#0078D4,color:#000
    style RB_STOP fill:#50E6FF,stroke:#0078D4,color:#000
    style LAW fill:#FFA500,stroke:#FF8C00,color:#000
    style AG fill:#FFD700,stroke:#FFA500,color:#000
    style VM1 fill:#90EE90,stroke:#32CD32,color:#000
    style VM2 fill:#90EE90,stroke:#32CD32,color:#000
    style VM3 fill:#90EE90,stroke:#32CD32,color:#000
    style EMAIL fill:#FFB6C1,stroke:#FF69B4,color:#000
</code></pre>

<h3 id="componentes-principales">Componentes Principales</h3>

<h4 id="1-automation-account">1. <strong>Automation Account</strong></h4>
<p>El cerebro de la operación. Configurado con:</p>
<ul>
  <li><strong>Identidad Administrada (System Assigned)</strong>: No requiere credenciales explícitas</li>
  <li><strong>Conectividad Privada</strong>: Mediante Private Endpoint para mayor seguridad</li>
  <li><strong>SKU Basic</strong>: Suficiente para la mayoría de casos de uso</li>
</ul>

<h4 id="2-runbooks-powershell">2. <strong>Runbooks PowerShell</strong></h4>
<p>Scripts que ejecutan la lógica de negocio:</p>
<ul>
  <li><code class="language-plaintext highlighter-rouge">vm-start.ps1</code>: Enciende VMs filtradas por tags</li>
  <li><code class="language-plaintext highlighter-rouge">vm-stop.ps1</code>: Apaga VMs filtradas por tags</li>
</ul>

<p>Cada runbook sigue este flujo:</p>

<pre><code class="language-mermaid">sequenceDiagram
    participant SCH as Automation Schedule
    participant RB as Runbook
    participant AAI as Managed Identity
    participant AZURE as Azure ARM
    participant VM as Virtual Machines
    participant AG as Action Group
    participant EMAIL as Email

    SCH-&gt;&gt;RB: Trigger (08:00 AM)
    activate RB
    RB-&gt;&gt;AAI: Connect-AzAccount -Identity
    AAI--&gt;&gt;RB: Token de autenticación
    
    RB-&gt;&gt;AZURE: Get-AzVM -Status
    AZURE--&gt;&gt;RB: Lista de VMs
    
    Note over RB: Filtrar VMs con&lt;br/&gt;tag: environment=pre
    
    loop Para cada VM objetivo
        RB-&gt;&gt;VM: Verificar PowerState
        VM--&gt;&gt;RB: Estado actual
        
        alt VM no está running
            RB-&gt;&gt;VM: Start-AzVM
            VM--&gt;&gt;RB: Operación iniciada
        else VM ya está running
            RB-&gt;&gt;RB: Skip
        end
    end
    
    Note over RB: Esperar 60 segundos
    RB-&gt;&gt;RB: Start-Sleep -Seconds 60
    
    RB-&gt;&gt;AZURE: Get-AzVM -Status (verificación)
    AZURE--&gt;&gt;RB: Estados actualizados
    
    RB-&gt;&gt;RB: Construir resumen
    
    RB-&gt;&gt;AG: POST /createNotifications
    AG-&gt;&gt;EMAIL: Enviar email con resumen
    
    deactivate RB
    
    Note over EMAIL: 📧 Resumen recibido:&lt;br/&gt;VMs running/stopped&lt;br/&gt;Errores si los hay
</code></pre>

<h4 id="3-schedules-automatizados">3. <strong>Schedules Automatizados</strong></h4>
<p>Programaciones que disparan los runbooks:</p>
<ul>
  <li><strong>Inicio</strong>: Lunes a Viernes, 08:00 AM (excluye fines de semana)</li>
  <li><strong>Parada</strong>: Viernes, 05:00 PM (las VMs permanecen apagadas todo el fin de semana)</li>
  <li><strong>Zona horaria</strong>: Europe/Madrid (configurable)</li>
  <li><strong>Días configurables</strong>: Puedes ajustar <code class="language-plaintext highlighter-rouge">week_days</code> para incluir/excluir días específicos</li>
</ul>

<h4 id="4-action-groups-y-notificaciones">4. <strong>Action Groups y Notificaciones</strong></h4>
<p>Sistema de notificaciones que envía emails con:</p>
<ul>
  <li>Lista de VMs que se iniciaron correctamente</li>
  <li>Lista de VMs que no pudieron iniciarse</li>
  <li>Detalles de errores encontrados</li>
  <li>Estado final de cada VM</li>
</ul>

<h2 id="implementación-paso-a-paso">Implementación Paso a Paso</h2>

<h3 id="paso-1-preparar-el-entorno">Paso 1: Preparar el Entorno</h3>

<p>Clona el repositorio:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/luisadanmunoz/start-stop-VM-alarm.git
<span class="nb">cd </span>start-stop-VM-alarm
</code></pre></div></div>

<h3 id="paso-2-configurar-variables">Paso 2: Configurar Variables</h3>

<p>Crea tu archivo <code class="language-plaintext highlighter-rouge">terraform.tfvars</code> basándote en el ejemplo:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Tags para identificar recursos</span>
<span class="nx">tags</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">environment</span> <span class="o">=</span> <span class="s2">"lab"</span>
  <span class="nx">project</span>     <span class="o">=</span> <span class="s2">"vm-automation"</span>
<span class="p">}</span>

<span class="c1"># Resource Group</span>
<span class="nx">rg</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">rg1</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">resource_group_name</span> <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">location</span>            <span class="o">=</span> <span class="s2">"spaincentral"</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="c1"># Automation Account</span>
<span class="nx">automation_accounts</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">aa-prod</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">automation_account_name</span>       <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">resource_group_name</span>           <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">location</span>                      <span class="o">=</span> <span class="s2">"spaincentral"</span>
    <span class="nx">sku_name</span>                      <span class="o">=</span> <span class="s2">"Basic"</span>
    <span class="nx">identity_type</span>                 <span class="o">=</span> <span class="s2">"SystemAssigned"</span>
    <span class="nx">public_network_access_enabled</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="nx">private_dns_zone_ids</span>          <span class="o">=</span> <span class="s2">"privatelink.azure-automation.net"</span>
    <span class="nx">subnet</span>                        <span class="o">=</span> <span class="s2">"subnet-automation"</span>
  <span class="err">}</span>
<span class="p">}</span>

<span class="c1"># Runbooks</span>
<span class="nx">automation_runbooks</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">rb_vm_start</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">resource_group_name</span>     <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">location</span>                <span class="o">=</span> <span class="s2">"spaincentral"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">runbook_type</span>            <span class="o">=</span> <span class="s2">"PowerShell"</span>
    <span class="nx">script_path</span>             <span class="o">=</span> <span class="s2">"runbooks/vm-start.ps1"</span>
    <span class="nx">description</span>             <span class="o">=</span> <span class="s2">"Arranque automático de VMs"</span>
  <span class="err">}</span>
  <span class="nx">rb_vm_stop</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">resource_group_name</span>     <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">location</span>                <span class="o">=</span> <span class="s2">"spaincentral"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">runbook_type</span>            <span class="o">=</span> <span class="s2">"PowerShell"</span>
    <span class="nx">script_path</span>             <span class="o">=</span> <span class="s2">"runbooks/vm-stop.ps1"</span>
    <span class="nx">description</span>             <span class="o">=</span> <span class="s2">"Parada automática de VMs"</span>
  <span class="err">}</span>
<span class="p">}</span>

<span class="c1"># Schedules</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="c1"># OPCIÓN 1: Solo días laborables (Lunes a Viernes) - SIN FINES DE SEMANA</span>
  <span class="nx">sch_vm_start_weekdays_0800</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"sch-vm-start-weekdays-0800"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T08:00:00+01:00"</span>  <span class="c1"># Debe ser lunes</span>
    <span class="nx">vm_start_schedule_description</span>     <span class="o">=</span> <span class="s2">"Arranque automático VMs pre (L-V)"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"pre"</span>
  <span class="err">}</span>
  <span class="nx">sch_vm_stop_weekdays_1700</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"sch-vm-stop-weekdays-1700"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-24T17:00:00+01:00"</span>  <span class="c1"># Debe ser viernes</span>
    <span class="nx">vm_start_schedule_description</span>     <span class="o">=</span> <span class="s2">"Parada automática VMs pre (L-V)"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"pre"</span>
  <span class="err">}</span>
  
  <span class="c1"># OPCIÓN 2: Todos los días (incluidos fines de semana)</span>
  <span class="c1"># Descomenta si prefieres que se ejecute también sábados y domingos</span>
  <span class="cm">/*
  sch_vm_start_daily_0800 = {
    name                              = "sch-vm-start-daily-0800"
    resource_group_name               = "rg-automation-lab"
    automation_account_name           = "aa-vm-automation"
    frequency                         = "Day"
    interval                          = 1
    vm_start_schedule_start_time      = "2026-01-23T08:00:00+01:00"
    vm_start_schedule_description     = "Arranque automático VMs pre (diario)"
    vm_start_schedule_timezone        = "Europe/Madrid"
    runbook_name                      = "rb_vm_start"
    tag_key                           = "environment"
    tag_value                         = "pre"
  }
  sch_vm_stop_daily_1700 = {
    name                              = "sch-vm-stop-daily-1700"
    resource_group_name               = "rg-automation-lab"
    automation_account_name           = "aa-vm-automation"
    frequency                         = "Day"
    interval                          = 1
    vm_start_schedule_start_time      = "2026-01-23T17:00:00+01:00"
    vm_start_schedule_description     = "Parada automática VMs pre (diario)"
    vm_start_schedule_timezone        = "Europe/Madrid"
    runbook_name                      = "rb_vm_stop"
    tag_key                           = "environment"
    tag_value                         = "pre"
  }
  */</span>
<span class="p">}</span>

<span class="c1"># Action Group para notificaciones</span>
<span class="nx">monitor_action_group</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">ag_lab_email</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                            <span class="o">=</span> <span class="s2">"ag-vm-automation-email"</span>
    <span class="nx">resource_group_name</span>             <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">short_name</span>                      <span class="o">=</span> <span class="s2">"vmautoemail"</span>
    <span class="nx">email_receiver_name</span>             <span class="o">=</span> <span class="s2">"ops-team"</span>
    <span class="nx">email_receiver_email_address</span>    <span class="o">=</span> <span class="s2">"tu-email@dominio.com"</span>
    <span class="nx">automation_account_name</span>         <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="paso-3-configurar-role-assignments">Paso 3: Configurar Role Assignments</h3>

<p><strong>Importante</strong>: Los permisos deben configurarse con los IDs reales de tus recursos:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">role_assignments</span> <span class="o">=</span> <span class="p">{</span>
  <span class="c1"># Permiso para gestionar VMs</span>
  <span class="nx">ra_vm_contributor</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">scope</span>                   <span class="o">=</span> <span class="s2">"/subscriptions/{sub-id}/resourceGroups/{rg-vms}"</span>
    <span class="nx">role_definition_name</span>    <span class="o">=</span> <span class="s2">"Virtual Machine Contributor"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
  <span class="p">}</span>
  
  <span class="c1"># Permiso para monitorización</span>
  <span class="nx">ra_monitoring</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">scope</span>                   <span class="o">=</span> <span class="s2">"/subscriptions/{sub-id}/resourceGroups/rg-automation-lab"</span>
    <span class="nx">role_definition_name</span>    <span class="o">=</span> <span class="s2">"Monitoring Contributor"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
  <span class="p">}</span>
  
  <span class="c1"># Permiso sobre Action Group</span>
  <span class="nx">ra_action_group</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">scope</span>                   <span class="o">=</span> <span class="s2">"/subscriptions/{sub-id}/resourceGroups/rg-automation-lab/providers/Microsoft.Insights/actionGroups/ag-vm-automation-email"</span>
    <span class="nx">role_definition_name</span>    <span class="o">=</span> <span class="s2">"Contributor"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
  <span class="p">}</span>
  
  <span class="c1"># Permiso para leer Log Analytics</span>
  <span class="nx">ra_law_reader</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">scope</span>                   <span class="o">=</span> <span class="s2">"/subscriptions/{sub-id}/resourceGroups/rg-automation-lab/providers/Microsoft.OperationalInsights/workspaces/law-automation"</span>
    <span class="nx">role_definition_name</span>    <span class="o">=</span> <span class="s2">"Log Analytics Reader"</span>
    <span class="nx">automation_account_name</span> <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="paso-4-desplegar-con-terraform">Paso 4: Desplegar con Terraform</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Inicializar Terraform</span>
terraform init

<span class="c"># Validar configuración</span>
terraform validate

<span class="c"># Ver plan de ejecución</span>
terraform plan

<span class="c"># Aplicar cambios</span>
terraform apply
</code></pre></div></div>

<h3 id="paso-5-configurar-variable-en-automation-account">Paso 5: Configurar Variable en Automation Account</h3>

<p>Después del despliegue, configura manualmente la variable <code class="language-plaintext highlighter-rouge">ACTION_GROUP_ID</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Obtener el ID del Action Group</span>
<span class="nv">ACTION_GROUP_ID</span><span class="o">=</span><span class="si">$(</span>az monitor action-group show <span class="se">\</span>
  <span class="nt">--name</span> ag-vm-automation-email <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-automation-lab <span class="se">\</span>
  <span class="nt">--query</span> <span class="nb">id</span> <span class="nt">-o</span> tsv<span class="si">)</span>

<span class="c"># Crear la variable en el Automation Account</span>
az automation variable create <span class="se">\</span>
  <span class="nt">--automation-account-name</span> aa-vm-automation <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-automation-lab <span class="se">\</span>
  <span class="nt">--name</span> ACTION_GROUP_ID <span class="se">\</span>
  <span class="nt">--value</span> <span class="s2">"</span><span class="nv">$ACTION_GROUP_ID</span><span class="s2">"</span>
</code></pre></div></div>

<p>O manualmente desde el portal de Azure:</p>
<ol>
  <li>Navega al Automation Account</li>
  <li>Ve a <strong>Variables</strong> en el menú lateral</li>
  <li>Crea variable <code class="language-plaintext highlighter-rouge">ACTION_GROUP_ID</code> con el resource ID del Action Group</li>
</ol>

<h3 id="paso-6-etiquetar-vms">Paso 6: Etiquetar VMs</h3>

<p>Para que una VM sea gestionada por la automatización, añádele el tag apropiado:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Mediante Azure CLI</span>
az vm update <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-vms-prod <span class="se">\</span>
  <span class="nt">--name</span> vm-web-app-01 <span class="se">\</span>
  <span class="nt">--set</span> tags.environment<span class="o">=</span>pre

<span class="c"># Mediante Terraform</span>
resource <span class="s2">"azurerm_linux_virtual_machine"</span> <span class="s2">"web_app"</span> <span class="o">{</span>
  name                <span class="o">=</span> <span class="s2">"vm-web-app-01"</span>
  resource_group_name <span class="o">=</span> <span class="s2">"rg-vms-prod"</span>
  location            <span class="o">=</span> <span class="s2">"spaincentral"</span>
  <span class="c"># ... otras configuraciones ...</span>
  
  tags <span class="o">=</span> <span class="o">{</span>
    environment <span class="o">=</span> <span class="s2">"pre"</span>
    managed_by  <span class="o">=</span> <span class="s2">"automation"</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="detalles-técnicos-de-los-runbooks">Detalles Técnicos de los Runbooks</h2>

<h3 id="script-vm-startps1">Script vm-start.ps1</h3>

<p>El runbook de inicio implementa una lógica robusta:</p>

<div class="language-powershell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">param</span><span class="p">(</span><span class="w">
  </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$tagkey</span><span class="w">   </span><span class="o">=</span><span class="w"> </span><span class="s2">"environment"</span><span class="p">,</span><span class="w">
  </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$tagvalue</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"pre"</span><span class="p">,</span><span class="w">
  </span><span class="p">[</span><span class="n">string</span><span class="p">]</span><span class="nv">$subscriptionid</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">""</span><span class="p">,</span><span class="w">
  </span><span class="p">[</span><span class="n">int</span><span class="p">]</span><span class="nv">$waitseconds</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">60</span><span class="w">
</span><span class="p">)</span><span class="w">

</span><span class="c"># 1. Autenticación con Managed Identity</span><span class="w">
</span><span class="n">Connect-AzAccount</span><span class="w"> </span><span class="nt">-Identity</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">

</span><span class="c"># 2. Cambiar contexto si se especifica suscripción</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$subscriptionid</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="nv">$subscriptionid</span><span class="o">.</span><span class="nf">Trim</span><span class="p">()</span><span class="o">.</span><span class="nf">Length</span><span class="w"> </span><span class="o">-gt</span><span class="w"> </span><span class="nx">0</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="n">Set-AzContext</span><span class="w"> </span><span class="nt">-Subscription</span><span class="w"> </span><span class="nv">$subscriptionid</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 3. Obtener VMs con el tag especificado</span><span class="w">
</span><span class="nv">$vms</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzVM</span><span class="w"> </span><span class="nt">-Status</span><span class="w">
</span><span class="nv">$targets</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$vms</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Tags</span><span class="o">.</span><span class="nf">ContainsKey</span><span class="p">(</span><span class="nv">$tagkey</span><span class="p">)</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="n">Tags</span><span class="p">[</span><span class="nv">$tagkey</span><span class="p">]</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$tagvalue</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 4. Cargar Action Group ID</span><span class="w">
</span><span class="nv">$actionGroupId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AutomationVariable</span><span class="w"> </span><span class="nt">-Name</span><span class="w"> </span><span class="s2">"ACTION_GROUP_ID"</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="nv">$actionGroupId</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w"> 
  </span><span class="kr">throw</span><span class="w"> </span><span class="s2">"No existe la variable ACTION_GROUP_ID"</span><span class="w"> 
</span><span class="p">}</span><span class="w">

</span><span class="c"># 5. Si no hay VMs, notificar y salir</span><span class="w">
</span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="o">-not</span><span class="w"> </span><span class="nv">$targets</span><span class="w"> </span><span class="o">-or</span><span class="w"> </span><span class="nv">$targets</span><span class="o">.</span><span class="nf">Count</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="mi">0</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="n">Send-ActionGroupMail</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="s2">"AVISO: no hay VMs con tag (</span><span class="nv">$tagkey</span><span class="s2">=</span><span class="nv">$tagvalue</span><span class="s2">)"</span><span class="w"> </span><span class="se">`
</span><span class="w">    </span><span class="s2">"El runbook se ejecutó pero no encontró VMs."</span><span class="w">
  </span><span class="kr">return</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 6. Intentar iniciar cada VM</span><span class="w">
</span><span class="nv">$startErrors</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@()</span><span class="w">
</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$vm</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$targets</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nv">$powerState</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Statuses</span><span class="w"> </span><span class="o">|</span><span class="w"> 
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Code</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"PowerState/*"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">|</span><span class="w"> 
    </span><span class="n">Select-Object</span><span class="w"> </span><span class="nt">-First</span><span class="w"> </span><span class="nx">1</span><span class="p">)</span><span class="o">.</span><span class="nf">DisplayStatus</span><span class="w">
  
  </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$powerState</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"VM running"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Write-Output</span><span class="w"> </span><span class="s2">"  - VM </span><span class="si">$(</span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Name</span><span class="si">)</span><span class="s2"> ya está arrancada. Skip."</span><span class="w">
    </span><span class="kr">continue</span><span class="w">
  </span><span class="p">}</span><span class="w">
  
  </span><span class="kr">try</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="n">Start-AzVM</span><span class="w"> </span><span class="nt">-ResourceGroupName</span><span class="w"> </span><span class="nv">$vm</span><span class="o">.</span><span class="nf">ResourceGroupName</span><span class="w"> </span><span class="se">`
</span><span class="w">              </span><span class="nt">-Name</span><span class="w"> </span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Name</span><span class="w"> </span><span class="se">`
</span><span class="w">              </span><span class="nt">-ErrorAction</span><span class="w"> </span><span class="nx">Stop</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Out-Null</span><span class="w">
    </span><span class="nx">Write-Output</span><span class="w"> </span><span class="s2">"  - VM </span><span class="si">$(</span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Name</span><span class="si">)</span><span class="s2"> iniciada correctamente"</span><span class="w">
  </span><span class="p">}</span><span class="w">
  </span><span class="kr">catch</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$startErrors</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="p">[</span><span class="n">PSCustomObject</span><span class="p">]@{</span><span class="w"> 
      </span><span class="nx">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">$vm</span><span class="err">.</span><span class="nx">Name</span><span class="w">
      </span><span class="nx">error</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="bp">$_</span><span class="err">.</span><span class="nx">Exception</span><span class="err">.</span><span class="nx">Message</span><span class="w"> 
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 7. Esperar y re-verificar estados</span><span class="w">
</span><span class="n">Start-Sleep</span><span class="w"> </span><span class="nt">-Seconds</span><span class="w"> </span><span class="nv">$waitseconds</span><span class="w">

</span><span class="nv">$after</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">Get-AzVM</span><span class="w"> </span><span class="nt">-Status</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Tags</span><span class="o">.</span><span class="nf">ContainsKey</span><span class="p">(</span><span class="nv">$tagkey</span><span class="p">)</span><span class="w"> </span><span class="o">-and</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="n">Tags</span><span class="p">[</span><span class="nv">$tagkey</span><span class="p">]</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="nv">$tagvalue</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 8. Clasificar resultados</span><span class="w">
</span><span class="nv">$running</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@()</span><span class="w">
</span><span class="nv">$notRunning</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">@()</span><span class="w">

</span><span class="kr">foreach</span><span class="w"> </span><span class="p">(</span><span class="nv">$vm</span><span class="w"> </span><span class="kr">in</span><span class="w"> </span><span class="nv">$after</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
  </span><span class="nv">$ps</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Statuses</span><span class="w"> </span><span class="o">|</span><span class="w"> 
    </span><span class="n">Where-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="bp">$_</span><span class="o">.</span><span class="nf">Code</span><span class="w"> </span><span class="o">-like</span><span class="w"> </span><span class="s2">"PowerState/*"</span><span class="w"> </span><span class="p">})</span><span class="o">.</span><span class="nf">DisplayStatus</span><span class="w">
  
  </span><span class="kr">if</span><span class="w"> </span><span class="p">(</span><span class="nv">$ps</span><span class="w"> </span><span class="o">-eq</span><span class="w"> </span><span class="s2">"VM running"</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$running</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Name</span><span class="w">
  </span><span class="p">}</span><span class="w"> </span><span class="kr">else</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nv">$notRunning</span><span class="w"> </span><span class="o">+=</span><span class="w"> </span><span class="s2">"</span><span class="si">$(</span><span class="nv">$vm</span><span class="o">.</span><span class="nf">Name</span><span class="si">)</span><span class="s2"> [</span><span class="nv">$ps</span><span class="s2">]"</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">

</span><span class="c"># 9. Enviar resumen por email</span><span class="w">
</span><span class="nv">$subject</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"Resultado start VMs por tag (</span><span class="nv">$tagkey</span><span class="s2">=</span><span class="nv">$tagvalue</span><span class="s2">)"</span><span class="w">
</span><span class="nv">$desc</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sh">@"
Resumen:
- Running: </span><span class="si">$(</span><span class="nv">$running</span><span class="w"> </span><span class="o">-join</span><span class="w"> </span><span class="s2">", "</span><span class="si">)</span><span class="sh">
- No running: </span><span class="si">$(</span><span class="nv">$notRunning</span><span class="w"> </span><span class="o">-join</span><span class="w"> </span><span class="s2">", "</span><span class="si">)</span><span class="sh">
- Errores: </span><span class="si">$(</span><span class="nv">$startErrors</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="n">ForEach-Object</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="s2">"</span><span class="si">$(</span><span class="bp">$_</span><span class="o">.</span><span class="nf">name</span><span class="si">)</span><span class="s2">: </span><span class="si">$(</span><span class="bp">$_</span><span class="o">.</span><span class="nf">error</span><span class="si">)</span><span class="s2">"</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">-join</span><span class="w"> </span><span class="s2">" | "</span><span class="si">)</span><span class="sh">
"@</span><span class="w">

</span><span class="n">Send-ActionGroupMail</span><span class="w"> </span><span class="nv">$subject</span><span class="w"> </span><span class="nv">$desc</span><span class="w">
</span></code></pre></div></div>

<h3 id="características-destacadas">Características Destacadas</h3>

<ol>
  <li><strong>Manejo de errores robusto</strong>: Captura y reporta errores individuales sin detener el proceso</li>
  <li><strong>Verificación post-inicio</strong>: Espera 60 segundos y verifica que las VMs realmente se iniciaron</li>
  <li><strong>Notificaciones detalladas</strong>: Informa sobre éxitos, fallos y VMs que ya estaban encendidas</li>
  <li><strong>Parámetros configurables</strong>: Permite personalizar tags, suscripción y tiempo de espera</li>
  <li><strong>Idempotencia</strong>: Detecta VMs ya encendidas y las omite</li>
</ol>

<h2 id="ejemplo-de-notificación-por-email">Ejemplo de Notificación por Email</h2>

<p>Cuando el runbook se ejecuta, recibes un email como este:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>De: Azure Action Group &lt;no-reply@microsoft.com&gt;
Para: ops-team@tudominio.com
Asunto: Resultado start VMs por tag (environment=pre)

Resumen de ejecución del runbook vm-start.ps1:

✅ VMs en estado Running:
   • vm-web-app-01
   • vm-api-backend-01
   • vm-database-01

⚠️ VMs que no se iniciaron:
   • vm-test-server-01 [VM deallocated]

❌ Errores encontrados:
   (ninguno)

Timestamp: 2026-01-22 08:01:23 UTC
Automation Account: aa-vm-automation
Runbook Job ID: 3f7a9c2b-4d8e-4f1a-9b6c-2e5d8f3a7c1b
</code></pre></div></div>

<h2 id="monitorización-con-log-analytics">Monitorización con Log Analytics</h2>

<p>Todos los logs se centralizan en Log Analytics Workspace. Puedes ejecutar queries KQL para analizar:</p>

<h3 id="consultar-ejecuciones-recientes">Consultar ejecuciones recientes</h3>

<pre><code class="language-kql">AzureDiagnostics
| where ResourceProvider == "MICROSOFT.AUTOMATION"
| where Category == "JobLogs"
| where RunbookName_s in ("rb_vm_start", "rb_vm_stop")
| project TimeGenerated, RunbookName_s, ResultType, JobId_g
| order by TimeGenerated desc
| take 50
</code></pre>

<h3 id="detectar-fallos-en-runbooks">Detectar fallos en runbooks</h3>

<pre><code class="language-kql">AzureDiagnostics
| where ResourceProvider == "MICROSOFT.AUTOMATION"
| where Category == "JobStreams"
| where ResultType == "Failed"
| project TimeGenerated, RunbookName_s, StreamType_s, ResultDescription
| order by TimeGenerated desc
</code></pre>

<h3 id="analizar-tiempo-de-ejecución">Analizar tiempo de ejecución</h3>

<pre><code class="language-kql">AzureDiagnostics
| where ResourceProvider == "MICROSOFT.AUTOMATION"
| where Category == "JobLogs"
| where ResultType == "Completed"
| extend Duration = datetime_diff('second', EndTime_t, CreationTime_t)
| summarize AvgDuration=avg(Duration), MaxDuration=max(Duration) 
    by RunbookName_s
</code></pre>

<h2 id="alertas-automáticas">Alertas Automáticas</h2>

<p>La solución incluye una alerta programada que se dispara cada vez que finaliza un job:</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">monitor_scheduled_query_rules_alert_v2</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">alert_automation_job_finished</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                     <span class="o">=</span> <span class="s2">"automation-runbook-finished"</span>
    <span class="nx">severity</span>                 <span class="o">=</span> <span class="mi">4</span>  <span class="c1"># Informational</span>
    <span class="nx">evaluation_frequency</span>     <span class="o">=</span> <span class="s2">"PT5M"</span>  <span class="c1"># Cada 5 minutos</span>
    <span class="nx">window_duration</span>          <span class="o">=</span> <span class="s2">"PT5M"</span>
    <span class="nx">time_aggregation_method</span>  <span class="o">=</span> <span class="s2">"Count"</span>
    <span class="nx">operator</span>                 <span class="o">=</span> <span class="s2">"GreaterThan"</span>
    <span class="nx">threshold</span>                <span class="o">=</span> <span class="mi">0</span>
    
    <span class="c1"># Query KQL</span>
    <span class="nx">query</span> <span class="o">=</span> <span class="o">&lt;&lt;-</span><span class="nx">QUERY</span>
      <span class="nx">AzureDiagnostics</span>
      <span class="o">|</span> <span class="nx">where</span> <span class="nx">ResourceProvider</span> <span class="o">==</span> <span class="s2">"MICROSOFT.AUTOMATION"</span>
      <span class="o">|</span> <span class="nx">where</span> <span class="nx">Category</span> <span class="o">==</span> <span class="s2">"JobLogs"</span>
      <span class="o">|</span> <span class="nx">where</span> <span class="nx">TimeGenerated</span> <span class="o">&gt;</span> <span class="nx">ago</span><span class="err">(</span><span class="mi">5m</span><span class="err">)</span>
      <span class="o">|</span> <span class="nx">where</span> <span class="nx">ResultType</span> <span class="nx">in</span> <span class="err">(</span><span class="s2">"Completed"</span><span class="p">,</span> <span class="s2">"Failed"</span><span class="p">,</span> <span class="s2">"Stopped"</span><span class="p">)</span>
    <span class="nx">QUERY</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="seguridad-y-mejores-prácticas">Seguridad y Mejores Prácticas</h2>

<h3 id="1-principio-de-mínimo-privilegio">1. <strong>Principio de Mínimo Privilegio</strong></h3>

<p>Los role assignments están limitados al ámbito mínimo necesario:</p>

<table>
  <thead>
    <tr>
      <th>Rol</th>
      <th>Ámbito</th>
      <th>Justificación</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Virtual Machine Contributor</td>
      <td>RG de VMs</td>
      <td>Solo para start/stop, no para crear/eliminar</td>
    </tr>
    <tr>
      <td>Monitoring Contributor</td>
      <td>RG del Automation</td>
      <td>Solo para escribir logs</td>
    </tr>
    <tr>
      <td>Contributor</td>
      <td>Action Group específico</td>
      <td>Solo para enviar notificaciones</td>
    </tr>
    <tr>
      <td>Log Analytics Reader</td>
      <td>Workspace específico</td>
      <td>Solo lectura de logs</td>
    </tr>
  </tbody>
</table>

<h3 id="2-conectividad-privada">2. <strong>Conectividad Privada</strong></h3>

<ul>
  <li><strong>Private Endpoint</strong>: El Automation Account no es accesible desde internet</li>
  <li><strong>Private DNS Zone</strong>: Resolución DNS privada para <code class="language-plaintext highlighter-rouge">privatelink.azure-automation.net</code></li>
  <li><strong>Network Security</strong>: Todo el tráfico permanece en la red virtual</li>
</ul>

<h3 id="3-identidad-administrada">3. <strong>Identidad Administrada</strong></h3>

<ul>
  <li>Sin credenciales almacenadas</li>
  <li>Rotación automática de tokens</li>
  <li>Auditoría completa en Azure AD</li>
</ul>

<h3 id="4-auditoría-y-compliance">4. <strong>Auditoría y Compliance</strong></h3>

<ul>
  <li>Todos los logs centralizados en Log Analytics</li>
  <li>Retención configurable (30 días por defecto)</li>
  <li>Trazabilidad completa de cada operación</li>
</ul>

<h2 id="costos-de-la-solución">Costos de la Solución</h2>

<p>Desglose de costos mensuales estimados (región Spain Central):</p>

<table>
  <thead>
    <tr>
      <th>Componente</th>
      <th>Costo Mensual</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Automation Account (Basic)</td>
      <td>Gratis</td>
    </tr>
    <tr>
      <td>Runbook execution (2 jobs/día × 2 min × 30 días)</td>
      <td>~€0.24</td>
    </tr>
    <tr>
      <td>Log Analytics (1 GB/mes ingerido)</td>
      <td>~€2.76</td>
    </tr>
    <tr>
      <td>Action Group (60 emails/mes)</td>
      <td>Gratis (primeros 1,000)</td>
    </tr>
    <tr>
      <td>Private Endpoint</td>
      <td>~€6.57</td>
    </tr>
    <tr>
      <td>Virtual Network</td>
      <td>Gratis</td>
    </tr>
    <tr>
      <td><strong>TOTAL</strong></td>
      <td><strong>~€9.57/mes</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="roi-cuánto-ahorras">ROI: ¿Cuánto ahorras?</h3>

<p>Si tienes <strong>10 VMs B2s</strong> (€30/mes cada una) encendidas 24/7 pero solo las necesitas 8h/día:</p>

<ul>
  <li><strong>Costo actual</strong>: 10 VMs × €30 = <strong>€300/mes</strong></li>
  <li><strong>Costo optimizado</strong>: 10 VMs × €30 × (8h/24h) = <strong>€100/mes</strong></li>
  <li><strong>Ahorro mensual</strong>: <strong>€200</strong></li>
  <li><strong>Costo de la solución</strong>: <strong>€9.57</strong></li>
  <li><strong>ROI</strong>: Recuperas la inversión en <strong>2 días</strong> ✅</li>
</ul>

<h2 id="casos-de-uso-reales">Casos de Uso Reales</h2>

<h3 id="1-entorno-de-desarrollo---solo-días-laborables-sin-fines-de-semana">1. Entorno de Desarrollo - Solo Días Laborables (SIN fines de semana)</h3>

<p><strong>Escenario</strong>: VMs de desarrollo que solo se necesitan de lunes a viernes, horario laboral 08:00-18:00.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VMs de desarrollo: encendidas 08:00-18:00 (lunes a viernes)</span>
<span class="c1"># Las VMs permanecen APAGADAS todo el fin de semana</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">dev_start_weekdays</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"dev-start-weekdays-0800"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T08:00:00+01:00"</span>  <span class="c1"># Lunes</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"dev"</span>
  <span class="err">}</span>
  <span class="nx">dev_stop_weekdays</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"dev-stop-weekdays-1800"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T18:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"dev"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>💰 Ahorro</strong>: Si tienes 10 VMs B2s (€30/mes cada una):</p>
<ul>
  <li><strong>Sin automatización</strong>: 10 VMs × €30 × 24h/día × 7 días = <strong>€300/mes</strong></li>
  <li><strong>Con automatización</strong> (10h/día × 5 días/semana): 10 VMs × €30 × (50h/168h) = <strong>€89/mes</strong></li>
  <li><strong>Ahorro mensual</strong>: <strong>€211</strong> (70% de reducción)</li>
</ul>

<h3 id="2-entorno-de-testing---todos-los-días-incluye-fines-de-semana">2. Entorno de Testing - Todos los Días (incluye fines de semana)</h3>

<p><strong>Escenario</strong>: VMs de testing que necesitan estar disponibles 7 días a la semana para pruebas automatizadas.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VMs de testing: solo durante horario extendido, todos los días</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">test_start_daily</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"test-start-daily-0700"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Day"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-23T07:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"test"</span>
  <span class="err">}</span>
  <span class="nx">test_stop_daily</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"test-stop-daily-2000"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Day"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-23T20:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"test"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>💰 Ahorro</strong>: 13 horas/día en lugar de 24 horas = <strong>46% de reducción de costos</strong></p>

<h3 id="3-preproducción---solo-durante-la-semana-laboral">3. Preproducción - Solo Durante la Semana Laboral</h3>

<p><strong>Escenario</strong>: Entorno de preproducción que solo necesita estar activo cuando el equipo está trabajando.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VMs de pre: Lunes inicio 07:00, Viernes parada 19:00</span>
<span class="c1"># Permanecen apagadas desde viernes noche hasta lunes mañana</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">pre_start_monday</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"pre-start-monday-0700"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T07:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"pre"</span>
  <span class="err">}</span>
  <span class="nx">pre_stop_friday</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"pre-stop-friday-1900"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-24T19:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"environment"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"pre"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>💡 Nota</strong>: Las VMs se encienden solo el lunes y se apagan el viernes, permaneciendo apagadas desde viernes 19:00 hasta lunes 07:00 (60 horas apagadas cada semana).</p>

<h3 id="4-vms-por-proyecto-con-horario-personalizado">4. VMs por Proyecto con Horario Personalizado</h3>

<p><strong>Escenario</strong>: Proyecto con equipo distribuido que trabaja en diferentes zonas horarias.</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VMs de proyecto específico con horario extendido</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">project_alpha_start</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"project-alpha-start-0600"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T06:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"project"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"alpha"</span>
  <span class="err">}</span>
  <span class="nx">project_alpha_stop</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"project-alpha-stop-2200"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Monday"</span><span class="p">,</span> <span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Wednesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">,</span> <span class="s2">"Friday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T22:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"project"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"alpha"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="5-configuración-avanzada---días-específicos">5. Configuración Avanzada - Días Específicos</h3>

<p><strong>Escenario</strong>: VMs que solo se usan ciertos días de la semana (por ejemplo, martes y jueves para demos).</p>

<div class="language-hcl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># VMs solo para demos: martes y jueves</span>
<span class="nx">automation_schedule</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">demo_start_tue_thu</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"demo-start-tue-thu-0900"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T09:00:00+01:00"</span>  <span class="c1"># Martes</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_start"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"purpose"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"demo"</span>
  <span class="err">}</span>
  <span class="nx">demo_stop_tue_thu</span> <span class="o">=</span> <span class="p">{</span>
    <span class="nx">name</span>                              <span class="o">=</span> <span class="s2">"demo-stop-tue-thu-1500"</span>
    <span class="nx">resource_group_name</span>               <span class="o">=</span> <span class="s2">"rg-automation-lab"</span>
    <span class="nx">automation_account_name</span>           <span class="o">=</span> <span class="s2">"aa-vm-automation"</span>
    <span class="nx">frequency</span>                         <span class="o">=</span> <span class="s2">"Week"</span>
    <span class="nx">interval</span>                          <span class="o">=</span> <span class="mi">1</span>
    <span class="nx">week_days</span>                         <span class="o">=</span> <span class="p">[</span><span class="s2">"Tuesday"</span><span class="p">,</span> <span class="s2">"Thursday"</span><span class="p">]</span>
    <span class="nx">vm_start_schedule_start_time</span>      <span class="o">=</span> <span class="s2">"2026-01-27T15:00:00+01:00"</span>
    <span class="nx">vm_start_schedule_timezone</span>        <span class="o">=</span> <span class="s2">"Europe/Madrid"</span>
    <span class="nx">runbook_name</span>                      <span class="o">=</span> <span class="s2">"rb_vm_stop"</span>
    <span class="nx">tag_key</span>                           <span class="o">=</span> <span class="s2">"purpose"</span>
    <span class="nx">tag_value</span>                         <span class="o">=</span> <span class="s2">"demo"</span>
  <span class="err">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><strong>💰 Ahorro</strong>: Solo 12 horas/semana en lugar de 168 horas = <strong>93% de reducción de costos</strong></p>

<h2 id="comparativa-de-configuraciones">Comparativa de Configuraciones</h2>

<table>
  <thead>
    <tr>
      <th>Configuración</th>
      <th>Horas/Semana Encendida</th>
      <th>% del Tiempo</th>
      <th>Ahorro vs 24/7</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>24/7 (sin automatización)</td>
      <td>168 horas</td>
      <td>100%</td>
      <td>0%</td>
    </tr>
    <tr>
      <td>Lunes-Viernes, 8h/día</td>
      <td>40 horas</td>
      <td>24%</td>
      <td><strong>76%</strong></td>
    </tr>
    <tr>
      <td>Lunes-Viernes, 10h/día</td>
      <td>50 horas</td>
      <td>30%</td>
      <td><strong>70%</strong></td>
    </tr>
    <tr>
      <td>Lunes-Viernes, 12h/día</td>
      <td>60 horas</td>
      <td>36%</td>
      <td><strong>64%</strong></td>
    </tr>
    <tr>
      <td>Todos los días, 12h/día</td>
      <td>84 horas</td>
      <td>50%</td>
      <td><strong>50%</strong></td>
    </tr>
    <tr>
      <td>Lunes inicio - Viernes parada</td>
      <td>120 horas</td>
      <td>71%</td>
      <td><strong>29%</strong></td>
    </tr>
    <tr>
      <td>Martes y Jueves, 6h/día</td>
      <td>12 horas</td>
      <td>7%</td>
      <td><strong>93%</strong></td>
    </tr>
  </tbody>
</table>

<h2 id="troubleshooting-común">Troubleshooting Común</h2>

<h3 id="problema-el-runbook-no-se-ejecuta">Problema: El runbook no se ejecuta</h3>

<p><strong>Solución</strong>:</p>
<ol>
  <li>Verifica que el schedule tenga una fecha de inicio futura</li>
  <li>Confirma que el runbook esté publicado (estado “Published”)</li>
  <li>Revisa los jobs en el Automation Account</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Listar jobs recientes</span>
az automation job list <span class="se">\</span>
  <span class="nt">--automation-account-name</span> aa-vm-automation <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-automation-lab
</code></pre></div></div>

<h3 id="problema-las-vms-no-cambian-de-estado">Problema: Las VMs no cambian de estado</h3>

<p><strong>Solución</strong>:</p>
<ol>
  <li>Verifica los permisos de la identidad administrada</li>
  <li>Confirma que las VMs tengan el tag correcto</li>
  <li>Revisa los logs del runbook</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Ver output del último job</span>
az automation job get-output <span class="se">\</span>
  <span class="nt">--automation-account-name</span> aa-vm-automation <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-automation-lab <span class="se">\</span>
  <span class="nt">--name</span> &lt;job-id&gt;
</code></pre></div></div>

<h3 id="problema-no-llegan-emails">Problema: No llegan emails</h3>

<p><strong>Solución</strong>:</p>
<ol>
  <li>Verifica que la variable <code class="language-plaintext highlighter-rouge">ACTION_GROUP_ID</code> esté configurada</li>
  <li>Confirma que el email sea correcto en el Action Group</li>
  <li>Revisa spam/correo no deseado</li>
  <li>Verifica permisos sobre el Action Group</li>
</ol>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Verificar Action Group</span>
az monitor action-group show <span class="se">\</span>
  <span class="nt">--name</span> ag-vm-automation-email <span class="se">\</span>
  <span class="nt">--resource-group</span> rg-automation-lab
</code></pre></div></div>

<h2 id="mejoras-futuras-y-roadmap">Mejoras Futuras y Roadmap</h2>

<p>Estoy trabajando en las siguientes mejoras para esta solución:</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Multi-suscripción</strong>: Gestionar VMs en múltiples suscripciones</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Retry logic</strong>: Reintentar operaciones fallidas automáticamente</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Dashboard personalizado</strong>: Visualizar métricas en Azure Dashboard</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Notificaciones a Teams</strong>: Integración con Microsoft Teams</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Start/stop bajo demanda</strong>: Webhook para control manual</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Machine Learning</strong>: Predicción de uso para optimizar horarios</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>Cost Management</strong>: Integración con Azure Cost Management API</li>
</ul>

<h2 id="conclusión">Conclusión</h2>

<p>Esta solución de automatización de VMs en Azure demuestra el poder de Infrastructure as Code combinado con las capacidades de automatización nativas de Azure. Con una inversión mínima (menos de €10/mes), puedes ahorrar cientos de euros al mes optimizando el uso de tus recursos.</p>

<p><strong>Beneficios clave</strong>:</p>
<ul>
  <li>✅ Reducción de costos de hasta 70% en entornos no productivos</li>
  <li>✅ Infraestructura 100% como código (reproducible y versionada)</li>
  <li>✅ Notificaciones automáticas para mantener visibilidad</li>
  <li>✅ Seguridad mejorada con Private Endpoints e Identidad Administrada</li>
  <li>✅ Monitorización completa con Log Analytics</li>
  <li>✅ Fácil de extender y personalizar</li>
</ul>

<h3 id="quieres-implementarlo">¿Quieres implementarlo?</h3>

<p>El código completo está disponible en mi repositorio de GitHub:</p>

<p>🔗 <strong><a href="https://github.com/luisadanmunoz/start-stop-VM-alarm">github.com/luisadanmunoz/start-stop-VM-alarm</a></strong></p>

<p>Si tienes preguntas o sugerencias, no dudes en abrir un issue en el repositorio o contactarme directamente.</p>

<hr />

<h2 id="recursos-adicionales">Recursos Adicionales</h2>

<ul>
  <li><a href="https://docs.microsoft.com/en-us/azure/automation/">Documentación Azure Automation</a></li>
  <li><a href="https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs">Terraform AzureRM Provider</a></li>
  <li><a href="https://docs.microsoft.com/en-us/azure/azure-monitor/alerts/action-groups">Azure Monitor Action Groups</a></li>
  <li><a href="https://docs.microsoft.com/en-us/powershell/azure/">PowerShell Az Module</a></li>
</ul>

<p><strong>Tags</strong>: <code class="language-plaintext highlighter-rouge">#Azure</code> <code class="language-plaintext highlighter-rouge">#Terraform</code> <code class="language-plaintext highlighter-rouge">#InfrastructureAsCode</code> <code class="language-plaintext highlighter-rouge">#Automation</code> <code class="language-plaintext highlighter-rouge">#DevOps</code> <code class="language-plaintext highlighter-rouge">#CostOptimization</code> <code class="language-plaintext highlighter-rouge">#CloudComputing</code> <code class="language-plaintext highlighter-rouge">#PowerShell</code></p>

<hr />

<p><em>¿Te ha resultado útil este artículo? Compártelo en tus redes sociales y ayuda a otros a optimizar sus costos en Azure.</em></p>]]></content><author><name>Luis Adán Muñoz</name></author><category term="Automatización" /><category term="Administración" /><category term="Azure" /><category term="Terraform" /><category term="Automation" /><category term="Infrastructure as Code" /><category term="DevOps" /><category term="PowerShell" /><summary type="html"><![CDATA[Aprende a implementar una solución completa de automatización para gestionar el encendido y apagado programado de máquinas virtuales en Azure usando Terraform, con notificaciones por email y monitorización integrada.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://luisadanmunoz.es/assets/img/og-default.png" /><media:content medium="image" url="https://luisadanmunoz.es/assets/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>