<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Morfa Vega</title>
    <description>The latest articles on DEV Community by Daniel Morfa Vega (@dmorfav).</description>
    <link>https://dev.to/dmorfav</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F775766%2Fabf6ff97-0fa7-4913-9359-16adfabe97e5.png</url>
      <title>DEV Community: Daniel Morfa Vega</title>
      <link>https://dev.to/dmorfav</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dmorfav"/>
    <language>en</language>
    <item>
      <title>Tu base de datos es tu backend: reglas de Firestore que no se fían ni de tu propio cliente</title>
      <dc:creator>Daniel Morfa Vega</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:55:38 +0000</pubDate>
      <link>https://dev.to/dmorfav/tu-base-de-datos-es-tu-backend-reglas-de-firestore-que-no-se-fian-ni-de-tu-propio-cliente-39nf</link>
      <guid>https://dev.to/dmorfav/tu-base-de-datos-es-tu-backend-reglas-de-firestore-que-no-se-fian-ni-de-tu-propio-cliente-39nf</guid>
      <description>&lt;p&gt;&lt;a href="https://www.cuentopia.es/" rel="noopener noreferrer"&gt;Cuentopia&lt;/a&gt; es una app Ionic + Angular que escribe cuentos para ayudar a las familias a explicar cosas difíciles a los peques. Por dentro es Firebase: el cliente habla directo con Firestore, sin un servidor mío en medio.&lt;/p&gt;

&lt;p&gt;Eso es medio backend que no tengo que escribir, mantener ni pagar. También significa que mi base de datos está publicada en internet con una API REST y gRPC documentada, y que lo único que hay entre esa API y los datos de una familia son unas reglas declarativas que escribí yo.&lt;/p&gt;

&lt;p&gt;Voy a contar cómo las pensé, con los tres bugs que me costaron una tarde cada uno. &lt;/p&gt;

&lt;p&gt;Todo el código de este artículo es de una app inventada de avistamientos de aves, no de Cuentopia: mismos patrones, nombres y esquema distintos, por razones obvias. Es código real de reglas, no pseudocódigo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que Firestore te concede cuando no dices nada
&lt;/h2&gt;

&lt;p&gt;Cuando abres un proyecto en modo de pruebas, Firestore te da esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acceso total a todo, para cualquiera, hasta una fecha. La consola te avisa. Casi nadie lo lee dos veces, porque durante las primeras semanas funciona perfecto: la app va, los datos se guardan, nada falla. El único síntoma de que no tienes seguridad es que todo va muy bien.&lt;/p&gt;

&lt;p&gt;El paso siguiente que da casi todo el mundo, y que yo di también, es este:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esto no es seguridad. Es una lista de invitados con la puerta abierta. &lt;code&gt;request.auth != null&lt;/code&gt; significa "alguien se autenticó", y en un proyecto con login por Google eso significa "cualquier persona del planeta con una cuenta de Google". Un usuario legítimo, con su sesión legítima, abre la consola del navegador, y desde ahí tiene exactamente los mismos permisos que tu app: leer cualquier documento de cualquier otro usuario, y escribir lo que le dé la gana en él.&lt;/p&gt;

&lt;p&gt;La distinción que hay que tener grabada es autenticación contra autorización. Firestore te resuelve la primera. La segunda es tuya, entera, y se escribe aquí.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deny-by-default no es la regla del final del archivo
&lt;/h2&gt;

&lt;p&gt;Casi todas las guías terminan el archivo de reglas con esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yo lo tengo. Y funciona. Pero durante un tiempo lo entendí mal, y creo que mucha gente lo entiende mal igual, así que conviene decirlo claro: ese bloque &lt;strong&gt;no es un cortafuegos que revoca nada&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Las reglas de Firestore no se evalúan en cascada como un firewall, donde la última coincidencia gana. Se evalúan como un OR: se busca cualquier regla &lt;code&gt;allow&lt;/code&gt; cuyo &lt;code&gt;match&lt;/code&gt; encaje con la ruta y cuya condición sea cierta. Si existe una sola, el acceso se concede. &lt;strong&gt;Ninguna regla puede quitar un permiso que otra ha dado.&lt;/strong&gt; Un &lt;code&gt;allow ...: if false&lt;/code&gt; no deniega: simplemente no concede.&lt;/p&gt;

&lt;p&gt;O sea que el catch-all del final es, funcionalmente, un no-op. Su valor es documental: deja escrito que cualquier ruta no contemplada está cerrada, y que eso es deliberado. Lo mantengo por eso, y porque el día que alguien abra el archivo entienda de qué va la casa.&lt;/p&gt;

&lt;p&gt;El deny-by-default de verdad es el comportamiento por defecto de la plataforma: si ninguna regla concede, se deniega. Lo cual invierte por completo dónde está el peligro. El peligro no es olvidarte de cerrar una colección nueva, porque nace cerrada. El peligro es haber dejado arriba un &lt;code&gt;match /{document=**}&lt;/code&gt; permisivo, porque entonces &lt;strong&gt;da igual lo cuidadosas que sean tus reglas de abajo&lt;/strong&gt;: ya hay un &lt;code&gt;allow&lt;/code&gt; que encaja con todo y que concede antes de que las tuyas opinen.&lt;/p&gt;

&lt;p&gt;Escribir reglas restrictivas debajo de una regla amplia y creer que las restringes es el error conceptual más caro de este modelo. No las restringes. Las estás decorando.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un documento entrante es un payload que escribió un desconocido
&lt;/h2&gt;

&lt;p&gt;Con eso claro, el resto del trabajo es aburrido en el buen sentido. Estos son los ayudantes que uso en todo el archivo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;rules_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;service&lt;/span&gt; &lt;span class="nx"&gt;cloud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firestore&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;databases&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;database&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sr"&gt;/documents &lt;/span&gt;&lt;span class="err"&gt;{
&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Lista cerrada de claves. Si aparece una que no está, cae la escritura&lt;/span&gt;
    &lt;span class="c1"&gt;// ENTERA, no solo ese campo: no existe el "se guardó casi bien".&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;hasOnly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// affectedKeys() es el conjunto de claves cuyo valor cambia en esta&lt;/span&gt;
    &lt;span class="c1"&gt;// escritura. Si alguna protegida está dentro, se rechaza.&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toSet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;intersection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;affectedKeys&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boundedString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y así queda el documento de perfil:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;    &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;observers&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display_name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier_status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;credits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preferences&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
           &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;
        &lt;span class="c1"&gt;// Todo el mundo nace en el plan gratis con el saldo a cero. Cualquier&lt;/span&gt;
        &lt;span class="c1"&gt;// otro valor solo puede venir del servidor, y el servidor no entra por&lt;/span&gt;
        &lt;span class="c1"&gt;// aquí.&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;free&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credits&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;display_name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier_status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;credits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preferences&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
           &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="c1"&gt;// Sin esta línea, "editar mi perfil" incluye "regalarme el plan de&lt;/span&gt;
        &lt;span class="c1"&gt;// pago y recargarme el saldo". No hace falta ningún exploit: basta&lt;/span&gt;
        &lt;span class="c1"&gt;// con que la regla no lo prohíba.&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tier_status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;credits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos cosas de aquí me parecen las que más rentan.&lt;/p&gt;

&lt;p&gt;La primera es &lt;code&gt;hasOnly&lt;/code&gt;. Sin ella, la regla valida los campos que tú conoces e ignora en silencio los que no. Un cliente puede colar &lt;code&gt;is_verified: true&lt;/code&gt;, o &lt;code&gt;internal_flags&lt;/code&gt;, o cualquier cosa, y se guardará, porque tu regla nunca preguntó por ella. Con la lista cerrada, ese documento se rechaza entero: el cliente recibe un &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; ruidoso en vez de escribir un campo fantasma que nadie descubrirá hasta que una agregación empiece a devolver números raros.&lt;/p&gt;

&lt;p&gt;La segunda es que la inmutabilidad de &lt;code&gt;tier&lt;/code&gt; y &lt;code&gt;credits&lt;/code&gt; no protege datos, protege privilegios. Si un campo del documento decide lo que su dueño puede hacer, su dueño no puede ser quien lo escribe. Esa frase se aplica sola en cuanto empiezas a mirar el esquema con ojos de atacante.&lt;/p&gt;

&lt;h2&gt;
  
  
  En un &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;request.resource.data&lt;/code&gt; es el documento entero
&lt;/h2&gt;

&lt;p&gt;Este es el primero de los tres bugs, y es el que más gente veo que se come.&lt;/p&gt;

&lt;p&gt;En un &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;request.resource.data&lt;/code&gt; es lo que manda el cliente. Intuitivo. En un &lt;code&gt;update&lt;/code&gt; también parece que debería ser lo que manda el cliente, pero no: es &lt;strong&gt;el documento resultante ya fusionado&lt;/strong&gt;, con todos los campos que ya estaban ahí y que esta escritura no toca.&lt;/p&gt;

&lt;p&gt;Lo que eso implica en la práctica: si un campo lo escribe tu servidor y por eso no está en la lista blanca del &lt;code&gt;create&lt;/code&gt;, tiene que estar sí o sí en la del &lt;code&gt;update&lt;/code&gt;. Si no, cualquier actualización posterior de ese documento, aunque no roce ese campo, morirá contra tu propio &lt;code&gt;hasOnly&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;En mi ejemplo de avistamientos, &lt;code&gt;coarse_cell&lt;/code&gt; es una celda geográfica aproximada que calcula un trigger del servidor a partir de la coordenada exacta, para poder hacer mapas de densidad sin exponer dónde está un nido. El cliente no la escribe nunca. Fíjate en la asimetría:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;      &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;sightings&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sightingId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;species_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seen_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;individuals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;habitat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coords_sealed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
             &lt;span class="p"&gt;])&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;sightingId&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;boundedString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;species_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seen_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;individuals&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;int&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;individuals&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;individuals&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
          &lt;span class="c1"&gt;// Texto libre sin tope = un documento de dos megas que te cuesta&lt;/span&gt;
          &lt;span class="c1"&gt;// dinero en CADA lectura que lo toque, para siempre.&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;boundedString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords_sealed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="c1"&gt;// 'coarse_cell' NO está en el create (lo escribe el trigger), pero&lt;/span&gt;
          &lt;span class="c1"&gt;// SÍ tiene que estar aquí: en un update, request.resource.data es el&lt;/span&gt;
          &lt;span class="c1"&gt;// doc completo ya fusionado. Sin esta clave, cualquier edición de un&lt;/span&gt;
          &lt;span class="c1"&gt;// avistamiento ya procesado revienta contra este mismo hasOnly.&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;species_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seen_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;individuals&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;habitat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coords_sealed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coarse_cell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updated_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
             &lt;span class="p"&gt;])&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;unchanged&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;coarse_cell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;boundedString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords_sealed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aparece en la lista blanca para poder pasar, y en &lt;code&gt;unchanged&lt;/code&gt; para que el cliente no pueda tocarla. Las dos cosas, o no sirve de nada.&lt;/p&gt;

&lt;p&gt;Este detalle tiene una consecuencia mayor que su propio bug: cada campo que añades al esquema te obliga a volver a las reglas. Es fricción real y la pagas en cada feature. También es lo que hace que un refactor descuidado se estrelle contra un &lt;code&gt;PERMISSION_DENIED&lt;/code&gt; en el emulador en vez de contra un &lt;code&gt;undefined is not a function&lt;/code&gt; en el móvil de alguien, seis meses después. Yo la considero mantenimiento, no impuesto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Leer un campo que no existe deniega la escritura entera
&lt;/h2&gt;

&lt;p&gt;Segundo bug, y este me tuvo mirando al cliente durante horas cuando el problema estaba en la regla.&lt;/p&gt;

&lt;p&gt;Si tu regla evalúa &lt;code&gt;request.resource.data.habitat in ['bosque', 'humedal']&lt;/code&gt; y el documento entrante no trae &lt;code&gt;habitat&lt;/code&gt;, la expresión no devuelve &lt;code&gt;false&lt;/code&gt;. Lanza un error de evaluación. Y un error de evaluación en una regla se traduce a &lt;code&gt;PERMISSION_DENIED&lt;/code&gt;, exactamente igual que una denegación legítima. Desde el cliente ves un permiso denegado que no tiene ningún sentido, y te pones a mirar la autenticación, que está perfecta.&lt;/p&gt;

&lt;p&gt;Para todo campo opcional hay que guardar la comprobación:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;          &lt;span class="c1"&gt;// `habitat` es opcional: hay avistamientos que solo registran especie&lt;/span&gt;
          &lt;span class="c1"&gt;// y hora. Sin este guardián, ese create legítimo se deniega por un&lt;/span&gt;
          &lt;span class="c1"&gt;// error de evaluación, no por falta de permisos.&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;habitat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
              &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;habitat&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bosque&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;humedal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;costa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;urbano&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;montana&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
                 &lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Es feo y hay que repetirlo campo a campo. No he encontrado nada mejor. El patrón que uso para no volver a caer: si el cliente puede omitir el campo en alguna ruta de código, la regla lo envuelve. Siempre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un regex sin estructura es una validación decorativa
&lt;/h2&gt;

&lt;p&gt;El tercero, y el que más me escoció, porque la regla existía, se leía bien y no servía para nada.&lt;/p&gt;

&lt;p&gt;Los campos sensibles se cifran en el cliente antes de tocar la base de datos, y llegan como un token con formato conocido. La regla estaba para que un cliente con un bug no pudiera guardar texto en claro donde tenía que ir cifrado. La escribí así:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;    &lt;span class="c1"&gt;// MAL: matches() evalúa la cadena completa, pero `.*` acepta cualquier&lt;/span&gt;
    &lt;span class="c1"&gt;// cosa que quepa en una línea. Esto valida el prefijo y nada más.&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;box:1:.*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;box:1:41.3874, 2.1686&lt;/code&gt; pasa esa validación sin despeinarse. El prefijo estaba, la coordenada iba en claro, y las reglas decían que todo correcto. Lo que hacía falta era describir la estructura y el alfabeto, no el principio de la cadena:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;    &lt;span class="c1"&gt;// Token cifrado en cliente: `box:1:&amp;lt;nonce_b64&amp;gt;:&amp;lt;ciphertext_b64&amp;gt;`.&lt;/span&gt;
    &lt;span class="c1"&gt;// El alfabeto base64 y las dos secciones son la validación de verdad; el&lt;/span&gt;
    &lt;span class="c1"&gt;// tope de tamaño evita que alguien use este campo como almacén gratis.&lt;/span&gt;
    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sealed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;box:1:[A-Za-z0-9+/=]+:[A-Za-z0-9+/=]+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La lección que me llevé no es sobre regex. Es que una validación que nunca has visto fallar no es una validación, es una decoración. Hasta que no escribí el test que intenta guardar &lt;code&gt;box:1:&lt;/code&gt; seguido de basura y espera un rechazo, esa función era una mentira que se leía muy bien en la revisión.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lo que decidí que las reglas no van a hacer
&lt;/h2&gt;

&lt;p&gt;Hay una tentación fuerte, cuando descubres que puedes validar tanto, de intentar validarlo todo aquí. He acabado con una línea bastante clara de lo que no entra.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nada que necesite estado.&lt;/strong&gt; Cuántas exportaciones puede pedir alguien al mes, cuántas peticiones por hora tolera una cuenta, si un contador cuadra: eso es lógica con transacciones y reintentos, y vive en el servidor. Una regla se evalúa por documento y por escritura, sin memoria de la anterior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nada que dependa de otro documento, salvo excepción justificada.&lt;/strong&gt; Puedes hacer &lt;code&gt;get()&lt;/code&gt; dentro de una regla, y a veces hace falta. Pero cada &lt;code&gt;get()&lt;/code&gt; se factura como una lectura más y se ejecuta en cada intento de escritura, incluidos los que van a fallar. Tengo exactamente uno en todo el archivo, y está ahí porque no había alternativa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nada sobre el significado.&lt;/strong&gt; Una regla comprueba que un texto mide entre 0 y 2000 caracteres. No puede comprobar si ese texto es apropiado, si tiene sentido o si es lo que el usuario cree que escribió. Eso pertenece a otra capa, y confundirlas es cómodo y falso.&lt;/p&gt;

&lt;p&gt;Lo que sí hago es estrechar el camino hasta que sea casi un ojo de cerradura. El caso que más me gusta es el de las operaciones largas. El cliente no ejecuta el trabajo ni escribe su resultado: solo deposita una solicitud inerte, y el servidor la recoge y mueve el estado con el Admin SDK, que &lt;strong&gt;se salta estas reglas por completo&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;      &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;export_jobs&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// El cliente solo puede dejar una solicitud parada en 'queued'. El&lt;/span&gt;
        &lt;span class="c1"&gt;// ciclo de vida (processing, done, failed) lo mueve el worker con el&lt;/span&gt;
        &lt;span class="c1"&gt;// Admin SDK, que no pasa por estas reglas. Así el cliente no puede&lt;/span&gt;
        &lt;span class="c1"&gt;// marcarse un trabajo como completado sin haberlo pagado ni ejecutado.&lt;/span&gt;
        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;onlyKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;owner_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range_from&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;range_to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
             &lt;span class="p"&gt;])&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;uid&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;queued&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;range_from&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;range_to&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="na"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Material criptográfico del usuario. No hay ninguna vía por la que un&lt;/span&gt;
      &lt;span class="c1"&gt;// cliente lea esto: lo maneja solo el servidor.&lt;/span&gt;
      &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;vault&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="o"&gt;=**&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Que el Admin SDK ignore las reglas es la pieza que hace que todo lo anterior sea sostenible. Las reglas no tienen que ser expresivas, tienen que ser estrechas. Todo lo que no cabe en ellas se sube un piso, donde hay un lenguaje de programación de verdad.&lt;/p&gt;

&lt;p&gt;Y una limitación que las reglas por sí solas no arreglan: verifican &lt;strong&gt;quién&lt;/strong&gt; hace la petición, no &lt;strong&gt;desde dónde&lt;/strong&gt;. Ahí es donde entra App Check, exigido en Firestore: un curl con un token de sesión robado, pero sin poder pasar la comprobación de dispositivo/app (reCAPTCHA v3 en web, Play Integrity o App Attest en nativo), se queda fuera antes de llegar siquiera a evaluar una regla.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reglas que se ejecutan, no que se leen
&lt;/h2&gt;

&lt;p&gt;Todo esto vive en un archivo declarativo que no compila contra nada, no tiene tipos y no falla hasta que un usuario le pega. La única forma de saber si dice lo que crees que dice es ejecutarlo.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@firebase/rules-unit-testing&lt;/code&gt; levanta el emulador y te deja lanzar operaciones como usuarios distintos. La suite tiene dos mitades, y la segunda es la que importa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;el dueño edita sus propias notas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;observerContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;obs_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertSucceeds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;updateDoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;observers/obs_1/sightings/s1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Dos ejemplares al amanecer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nadie se regala el plan de pago editando su perfil&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;observerContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;obs_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertFails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;updateDoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;observers/obs_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;serverTimestamp&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;un token cifrado mal formado no entra&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;observerContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;obs_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;assertFails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;setDoc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;observers/obs_1/sightings/s2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;validSighting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;obs_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;coords_sealed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;box:1:41.3874, 2.1686&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con una trampa que me parece importante y que casi nunca se menciona: &lt;strong&gt;&lt;code&gt;assertFails&lt;/code&gt; pasa por el motivo equivocado con muchísima facilidad&lt;/strong&gt;. Si construyes el documento de prueba a mano y se te olvida un campo obligatorio, el test pasa en verde porque la escritura falló, pero falló por la forma del documento, no por el permiso que creías estar probando. La regla que querías verificar puede estar rota y tu suite seguirá contenta.&lt;/p&gt;

&lt;p&gt;Por eso todos mis casos negativos parten de un documento válido y le cambian &lt;strong&gt;una sola cosa&lt;/strong&gt;, la que se está probando. Un helper que genera el documento correcto y un spread encima. Si el caso positivo equivalente no pasa en verde, el negativo no demuestra nada.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dónde estoy
&lt;/h2&gt;

&lt;p&gt;Nada de esto es sofisticado. Es un archivo largo, repetitivo y aburrido, que hay que tocar cada vez que cambia el esquema y que no le luce a nadie. A cambio, un cliente comprometido, un refactor mío mal pensado o un formulario con un bug producen un error ruidoso en vez de un dato podrido.&lt;/p&gt;

&lt;p&gt;Sigue habiendo huecos que conozco y no he cerrado. El &lt;code&gt;get()&lt;/code&gt; que tengo dentro de una regla me cuesta una lectura y algún día será un problema de coste. Y aunque tengo App Check exigido en Firestore (reduce la superficie de scripts sueltos con un token robado, no la cierra del todo), las reglas siguen sin poder distinguir a un usuario legítimo de su propia sesión robada desde su propia app.&lt;/p&gt;

&lt;p&gt;Si trabajas con Firestore y crees que el catch-all del final de tu archivo te está protegiendo de algo, revisa qué hay por encima. Es el rato mejor invertido de esta semana. Y si te apetece decirme dónde me estoy equivocando, la parte más útil de escribir esto es justo esa.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>angular</category>
      <category>security</category>
      <category>gcp</category>
    </item>
    <item>
      <title>Cómo pensamos el cifrado de PII en una app Ionic + Angular, para cumplir el RGPD y la LOPD-GDD</title>
      <dc:creator>Daniel Morfa Vega</dc:creator>
      <pubDate>Sun, 23 Aug 2026 18:27:04 +0000</pubDate>
      <link>https://dev.to/dmorfav/como-pensamos-el-cifrado-de-pii-en-una-app-ionic-angular-para-cumplir-el-rgpd-y-la-lopd-gdd-2o3g</link>
      <guid>https://dev.to/dmorfav/como-pensamos-el-cifrado-de-pii-en-una-app-ionic-angular-para-cumplir-el-rgpd-y-la-lopd-gdd-2o3g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Envelope encryption con clave por usuario, qué se cifra y qué no, cómo lo puso a prueba una auditoría externa, y el incidente de rendimiento que provocó nuestro propio hardening de seguridad.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Montaste tu app con IA rápido: le pides unos datos al usuario, llamas al modelo, guardas el resultado en la base de datos y a producción. Cómodo, sin complicaciones.&lt;/p&gt;

&lt;p&gt;Hasta que un día miras bien &lt;strong&gt;qué&lt;/strong&gt; estás guardando.&lt;/p&gt;

&lt;p&gt;En Cuentopia generamos cuentos personalizados para niños. Para personalizar, un padre nos cuenta cómo es su peque: su carácter, qué le da miedo, qué está pasando en casa. El modelo no improvisa sobre la marcha: se apoya en un marco de criterios clínicos y pedagógicos para decidir cómo abordar cada situación, y luego lo reescribe todo en prosa. Visto de golpe, lo que teníamos en la base de datos era el diario emocional de un montón de menores.&lt;/p&gt;

&lt;p&gt;El RGPD lo trata como categoría especialmente protegida. El sentido común, también. &lt;strong&gt;¿Y si se filtra la base de datos? ¿Y un backup mal guardado? ¿Y un acceso indebido con privilegios de admin?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Relájate (bueno, primero asústate un poco; luego relájate). Te voy a contar cómo pensamos el cifrado en reposo en serio: una arquitectura de tipo &lt;strong&gt;envelope encryption&lt;/strong&gt;, con una clave maestra que no sale nunca de &lt;strong&gt;Cloud KMS&lt;/strong&gt; (Google Cloud) y una clave por usuario que cifra los campos sensibles antes de que toquen la base de datos.&lt;/p&gt;

&lt;p&gt;Un aviso antes de seguir: te cuento el criterio y las decisiones, no el plano. No vas a encontrar aquí nombres de recursos, rutas de repositorio, ni el detalle exacto que le serviría de receta a alguien con ganas de probar suerte con nuestros datos. Y porque la seguridad honesta se cuenta entera, también te cuento dónde decidimos &lt;strong&gt;no&lt;/strong&gt; llegar y por qué.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;✨ &lt;strong&gt;Promesa:&lt;/strong&gt; al terminar vas a entender, con criterio real de producto, cómo una familia sin ser expertos en cripto se planteó cifrar datos de menores, y por qué ciertas decisiones muy concretas no se hacen públicas nunca, ni en el artículo más honesto.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  El mapa
&lt;/h2&gt;

&lt;p&gt;Lo construimos de dentro hacia fuera: primero el núcleo del cifrado, después una auditoría adversarial que nos bajó los humos y forzó varios cambios de endurecimiento, y por último, porque toda decisión de seguridad tiene una factura escondida, un incidente de rendimiento que nos costó un buen susto. Esta es esa historia, contada en fases, no en versiones ni en commits.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Diseño y cifrado base&lt;/strong&gt;: arquitectura envelope, clave por usuario, primeros campos cifrados.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auditoría y hardening&lt;/strong&gt;: pentest adversarial, cierre de hallazgos, borrado con crypto-shredding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ajuste de rendimiento&lt;/strong&gt;: un cambio de seguridad correcto que rompió la experiencia, y su arreglo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Una aclaración antes de seguir, porque importa: hicimos todo este proceso (diseño, auditoría, endurecimiento) con Cuentopia en beta muy temprana, con mi propia cuenta como único usuario real en la base de datos. Ningún dato de ninguna familia estuvo nunca ahí sin este cifrado; lo construimos antes de abrir la puerta a nadie más, precisamente porque hacerlo después, con usuarios reales y datos ya en producción, habría sido mucho más caro y mucho más arriesgado. Si quieres ver cómo se lo contamos a las familias, sin la jerga de este artículo, ahí está la versión pensada para ellas: &lt;a href="https://www.cuentopia.es/tu-confianza/" rel="noopener noreferrer"&gt;cuentopia.es/tu-confianza&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Y ya que estamos, crédito donde toca: todo esto corre sobre &lt;strong&gt;Firebase&lt;/strong&gt;: Firestore para los datos, Cloud Functions para la lógica de servidor, y &lt;strong&gt;Google Cloud KMS&lt;/strong&gt; para la clave maestra. No reinventamos gestión de claves: nos apoyamos en servicios gestionados que ya audita gente con más recursos que nosotros.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Cuentopia genera cuentos personalizados con IA a partir de datos sensibles de menores. La solución: &lt;strong&gt;cifrado de campos en reposo con envelope encryption&lt;/strong&gt;. Una clave maestra vive en &lt;strong&gt;Cloud KMS&lt;/strong&gt;, de Google Cloud, y nunca sale de ahí; cada usuario tiene su propia clave de datos, que la maestra envuelve y que se guarda cifrada junto a sus datos en &lt;strong&gt;Firestore&lt;/strong&gt;. El cliente pide su clave una vez por sesión, la usa en memoria para cifrar y descifrar campo a campo, y Firestore solo ve texto cifrado.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué ciframos y qué no (y el criterio que lo gobierna)
&lt;/h2&gt;

&lt;p&gt;Antes de tocar criptografía hubo una decisión de producto, refinada en una sesión de &lt;em&gt;grilling&lt;/em&gt; del diseño:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Se cifra&lt;/strong&gt;: el perfil del peque (nombre, rasgos, lo que le preocupa, lo que le hace ilusión), el contenido de los &lt;strong&gt;cuentos privados&lt;/strong&gt;, el contexto libre que escribe el adulto al pedir un cuento a medida, y las notas de los "momentos personalizados" que una familia documenta.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No se cifra&lt;/strong&gt;: los cuentos &lt;strong&gt;públicos&lt;/strong&gt; del catálogo, que no contienen datos reales de nadie: usan personajes ficticios.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;De ahí nace el criterio que gobierna todo el sistema:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Público ⇒ texto plano. Privado ⇒ cifrado.&lt;/strong&gt; Un remix de un cuento público entra por el canal privado, así que &lt;strong&gt;nace cifrado&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Perfil del peque&lt;/strong&gt;: lo cifra el cliente, al guardar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuentos privados&lt;/strong&gt;: los cifra el propio motor de generación, del lado servidor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contexto libre de la petición&lt;/strong&gt;: lo cifra el cliente, al guardar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notas y momentos personalizados&lt;/strong&gt;: las cifra el cliente, al guardar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Este criterio es el que permite que la lectura sea segura por defecto: si un documento no lleva metadata de cifrado, se asume texto plano y pasa tal cual. Así conviven, sin fricción, los cuentos públicos y los privados.&lt;/p&gt;

&lt;h2&gt;
  
  
  Envelope encryption, sin misterio
&lt;/h2&gt;

&lt;p&gt;El esquema es de libro, en tres niveles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloud KMS&lt;/strong&gt; (Google Cloud) guarda la clave maestra: nunca sale de ahí.&lt;/li&gt;
&lt;li&gt;La clave maestra envuelve y desenvuelve la &lt;strong&gt;clave de datos, por usuario&lt;/strong&gt;, que se guarda cifrada junto a los datos de cada cuenta.&lt;/li&gt;
&lt;li&gt;Esa clave de datos cifra y descifra los &lt;strong&gt;campos sensibles&lt;/strong&gt;, campo a campo: la base de datos solo ve texto cifrado.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Por qué envelope y no cifrar cada campo directo contra el gestor de claves:&lt;/strong&gt; un servicio gestionado de claves cobra y limita por operación, y no quieres mandarle cada campo del formulario. Con envelope, el gestor solo interviene una vez por sesión (para envolver o desenvolver la clave de datos); el cifrado masivo lo hace el propio cliente, en local.&lt;/p&gt;

&lt;p&gt;Dos decisiones de diseño que importan, sin entrar en el formato exacto:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cada valor cifrado queda atado a quién es su dueño y a qué campo pertenece.&lt;/strong&gt; Mover un valor cifrado de un campo a otro, o de una cuenta a otra, rompe la verificación. No es un detalle menor: convierte cualquier intento de "recombinar" datos cifrados en un fallo ruidoso, no en una fuga silenciosa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El formato de cifrado lleva versión desde el primer día.&lt;/strong&gt; Eso deja la puerta abierta a rotar el esquema completo sin tener que rediseñarlo sobre la marcha.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;🩺 &lt;strong&gt;Moraleja:&lt;/strong&gt; envelope &amp;gt; cifrar directo contra el gestor de claves. El gestor interviene una vez por sesión; el trabajo pesado lo hace el cliente, y te cuesta céntimos.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  La clave de cada usuario: caché, condiciones de carrera y quién puede pedirla
&lt;/h2&gt;

&lt;p&gt;El cliente nunca ve la clave maestra. Pide su clave de datos una única vez por sesión, a una función serverless de &lt;strong&gt;Firebase Cloud Functions&lt;/strong&gt;, y la mantiene en memoria, en un formato que no se puede exportar ni volcar. Esa clave se cachea localmente. Y ahí aparece una condición de carrera con la que merece la pena tener cuidado: si el usuario cierra sesión justo mientras la petición de clave sigue en vuelo, hay que descartar el resultado cuando llegue. Cachearlo de todos modos asociaría la clave del usuario anterior a la sesión nueva. Es el tipo de bug que no rompe nada en desarrollo y sí en producción, con dos pestañas abiertas.&lt;/p&gt;

&lt;p&gt;Del lado servidor, la generación de esa clave por usuario es &lt;strong&gt;idempotente&lt;/strong&gt;: la primera vez se genera, se envuelve con la clave maestra y se persiste; cualquier intento posterior devuelve siempre la misma, incluso si dos dispositivos intentan generarla a la vez en un primer login simultáneo: la que gana la carrera es la única verdad, y el "perdedor" recibe esa misma clave en lugar de crear la suya.&lt;/p&gt;

&lt;p&gt;Y aplicamos &lt;strong&gt;fail-closed&lt;/strong&gt; como principio, no como parche puntual: ante cualquier ambigüedad de configuración entre entornos, el sistema prefiere fallar de forma ruidosa antes que degradarse en silencio hacia un camino menos estricto.&lt;/p&gt;

&lt;p&gt;Y el acceso: la clave envuelta de cada usuario vive en una parte de la base de datos que el cliente &lt;strong&gt;nunca&lt;/strong&gt; puede leer directamente, solo el código que corre con privilegios de servidor. Esa separación es la que, más adelante, hace posible el borrado de cuentas de verdad (ver más abajo).&lt;/p&gt;

&lt;h2&gt;
  
  
  Que el servidor no se fíe del cliente
&lt;/h2&gt;

&lt;p&gt;El cifrado no se confía solo al cliente: las reglas de acceso de &lt;strong&gt;Firestore&lt;/strong&gt; &lt;strong&gt;rechazan&lt;/strong&gt; cualquier escritura que no tenga la forma exacta de un valor cifrado completo, de principio a fin: nada de subcadenas sueltas ni de texto en claro con un prefijo que lo disimule. Si el cliente tiene un bug y trata de escribir algo sin cifrar, el servidor lo rechaza antes de que llegue a guardarse.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🩺 &lt;strong&gt;Moraleja:&lt;/strong&gt; si el cliente puede equivocarse y escribir en claro, se equivocará. Haz que el servidor lo rechace. Las reglas de acceso son parte del esquema de cifrado, no un añadido.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  El pentest que nos bajó los humos
&lt;/h2&gt;

&lt;p&gt;Tras el rollout inicial nos dejamos auditar por fuera, con un enfoque adversarial: reglas de acceso, criptografía, el motor de generación y el tratamiento general de datos personales. El veredicto fue tranquilizador en lo esencial:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;El núcleo aguanta. El aislamiento entre cuentas es sólido: la clave de un usuario es inaccesible para otro, y eso no depende de nada que el cliente pueda manipular. &lt;strong&gt;No es cifrado de extremo a extremo&lt;/strong&gt;: quien consiguiera robar una sesión activa y superar las comprobaciones del cliente, podría obtener la clave desde el servidor. Los problemas reales estaban en canales laterales y en endurecer bordes, no en el núcleo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Aclarando esa frase, porque es la que más se malinterpreta: &lt;strong&gt;"no es E2E" no significa que los datos vayan sin cifrar&lt;/strong&gt;. Significa que la clave para descifrarlos no vive solo en el dispositivo de cada familia, como en una app de mensajería cifrada de verdad. Vive también en nuestra infraestructura (Cloud KMS y Firestore), porque es el propio servidor quien la genera y la entrega bajo demanda a quien se autentique correctamente. Protege frente a una fuga de base de datos, un backup robado o un acceso indebido a los datos en reposo. No protege frente a un compromiso del propio backend de Cuentopia: ahí seguimos siendo, técnicamente, un punto de confianza.&lt;/p&gt;

&lt;p&gt;¿Por qué no fuimos a por un E2E de verdad? Porque parte de lo que ciframos lo escribe el propio motor de generación &lt;strong&gt;en el servidor&lt;/strong&gt;: el cuento en sí. Un esquema E2E puro, donde ni el servidor puede descifrar nunca nada, es incompatible con tener una IA que necesita trabajar con ese contenido para escribirlo. Con esa limitación por delante, elegimos lo que sí podíamos ofrecer con solidez: aislamiento fuerte entre cuentas (la clave de una familia es inaccesible para otra, y esa sí es una garantía dura) y datos cifrados en todo momento en reposo.&lt;/p&gt;

&lt;p&gt;Para cerrar ese hueco de verdad haría falta mover el procesamiento del agente a un entorno de &lt;em&gt;confidential computing&lt;/em&gt; (enclaves con atestación remota, donde ni nuestra propia infraestructura pueda inspeccionar la memoria mientras se genera el cuento). No lo tenemos hoy; queda documentado como posible siguiente paso, no como excusa.&lt;/p&gt;

&lt;p&gt;Lo que se corrigió, por categorías:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Se ajustó la validación de formato para que fuera más estricta: la versión anterior, sin querer, dejaba colar algo parecido a texto en claro si llevaba el prefijo correcto delante.&lt;/li&gt;
&lt;li&gt;Se exigió una comprobación de integridad de la app más fuerte en la operación más sensible de todas (la que entrega la clave), cerrando así un camino de ataque que solo dependía de robar una sesión.&lt;/li&gt;
&lt;li&gt;Se encontró y cerró una fuga activa: un registro de depuración que, sin darnos cuenta, se llevaba datos ya descifrados a un sistema de logs que no tenía ningún motivo para verlos.&lt;/li&gt;
&lt;li&gt;Se reforzó el principio de fail-closed descrito arriba, cerrando un caso límite de configuración que podía dar lugar a ambigüedad.&lt;/li&gt;
&lt;li&gt;En vez de cifrar las imágenes de portada, se atacó la raíz del problema: se cambió cómo se le pide la imagen al modelo, para que ya no describa un retrato reconocible del peque.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ El bug más feo no era de criptografía: era un registro de depuración que se llevaba datos descifrados a un sistema que no pintaba nada ahí. Revisa tus logs. De verdad.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Y dos cambios que cierran obligaciones de RGPD:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Borrado de cuenta con crypto-shredding.&lt;/strong&gt; Borrar una cuenta borra, entre otras cosas, la clave de datos de esa persona. Sin esa clave, cualquier resto cifrado que pudiera quedar en una copia de seguridad es matemáticamente irrecuperable: destruir la clave equivale a destruir el dato, sin tener que perseguir cada copia una por una.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cierre de un hueco de texto en claro residual&lt;/strong&gt; en uno de los tipos de nota personal, que se nos había quedado fuera del primer barrido.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cuando el hardening te rompe el producto
&lt;/h2&gt;

&lt;p&gt;Exigir una comprobación de integridad más fuerte en la operación que entrega la clave parecía la decisión correcta. Lo era. Y aun así, rompió el producto.&lt;/p&gt;

&lt;p&gt;El síntoma me volvió loco un rato: la función respondía rápido y sin un solo error en sus registros. Pero abrir la ficha de un cuento privado se quedaba cargando &lt;strong&gt;casi un minuto&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;La trampa estaba fuera de la función. Si pides la clave &lt;strong&gt;de forma perezosa&lt;/strong&gt;, justo al abrir el cuento, y el token que certifica que la petición viene de una app legítima todavía no se ha generado, la llamada se rechaza &lt;strong&gt;antes&lt;/strong&gt; de llegar a entrar en la función. Por eso sus registros estaban limpios: el problema nunca llegaba hasta ahí. Y la pantalla, mientras tanto, reintentaba en bucle hasta agotar su margen de espera.&lt;/p&gt;

&lt;p&gt;La cripto no tenía la culpa. La tenía la carrera entre pedir la clave y que ese token de integridad estuviera listo.&lt;/p&gt;

&lt;p&gt;El arreglo fue dejar de pedir la clave en el peor momento posible: la calentamos &lt;strong&gt;al hacer login&lt;/strong&gt;, mientras el usuario todavía está en la pantalla de inicio, para que ya esté disponible cuando abra el primer cuento. Es un intento de mejor esfuerzo, con reintentos; si falla, la petición perezosa de siempre sigue actuando de red de seguridad.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🩺 &lt;strong&gt;Moraleja:&lt;/strong&gt; un cambio de seguridad puede ser correcto y aun así arruinar la experiencia. El hardening y el rendimiento se diseñan juntos, o te enteras en producción.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Qué no ciframos, con criterio
&lt;/h2&gt;

&lt;p&gt;Buena parte del trabajo "invisible" fue alinear la residencia de los datos: la base de datos, el gestor de claves, el código que toca estos datos y los modelos de IA que generan los cuentos viven todos dentro de la Unión Europea, backups incluidos.&lt;/p&gt;

&lt;p&gt;Y un sistema honesto documenta lo que &lt;strong&gt;no&lt;/strong&gt; hace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;El valor cifrado no lleva el identificador del documento al que pertenece&lt;/strong&gt;, solo el dueño y el campo. En teoría, alguien que ya tuviera la clave de una cuenta podría copiar un valor cifrado entre dos documentos suyos del mismo campo. Lo evaluamos y lo &lt;strong&gt;deferimos con criterio&lt;/strong&gt;: quien ya tiene esa clave puede reescribir cualquier cosa de todos modos, así que el riesgo añadido es bajo frente al coste de rehacer el esquema y volver a cifrar todos los datos existentes. Documentado, no implementado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las imágenes de portada no van cifradas.&lt;/strong&gt; Decisión deliberada: en vez de cifrar la imagen, se eliminó el dato personal de su origen: el modelo ya no genera un retrato reconocible del peque, sino un personaje estilizado y ficticio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los datos del adulto&lt;/strong&gt; (correo, nombre de la cuenta, datos de facturación) son otro nivel de sensibilidad y no entran en este esquema; cifrarlos rompería la integración con el proveedor de pagos. Decisión de producto, no descuido.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un bloqueo biométrico de la app&lt;/strong&gt; se evaluó y se aparcó: la versión criptográficamente sólida no compensaba (no cierra el hueco de "sesión robada" y arriesga pérdida de acceso si alguien olvida su PIN); una versión más simple, solo para bloquear la pantalla sin tocar las claves, queda como posible mejora futura.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;La rotación de la clave maestra&lt;/strong&gt; queda fuera de alcance por ahora: el formato versionado deja la puerta abierta para el día que haga falta.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🩺 &lt;strong&gt;Moraleja:&lt;/strong&gt; un sistema honesto enumera lo que NO hace. "No es cifrado de extremo a extremo" no es una vergüenza; es una decisión de diseño que se escribe.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Qué te llevas (y qué nos llevamos)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Envelope encryption es el patrón correcto&lt;/strong&gt; para datos personales en reposo en una app móvil/web con backend gestionado: el servicio de claves guarda la maestra, el cliente hace el trabajo masivo, y el coste es de céntimos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atar cada valor cifrado a su dueño y su campo vale oro.&lt;/strong&gt; Convierte cualquier intento de mover datos cifrados de sitio en un fallo de verificación, no en una fuga silenciosa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las reglas de acceso son parte del esquema de cifrado&lt;/strong&gt;, no un añadido: rechazar texto en claro en el servidor es lo que hace que un bug del cliente no se convierta en una fuga.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto-shredding hace operativo el "derecho al olvido".&lt;/strong&gt; Borrar una clave es instantáneo y alcanza incluso las copias de seguridad; borrar datos a mano, no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un sistema de cifrado honesto enumera su modelo de amenaza y sus huecos.&lt;/strong&gt; "No es de extremo a extremo" no es una vergüenza: es una decisión de diseño que hay que escribir.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;El hardening tiene secuelas.&lt;/strong&gt; Una comprobación de seguridad correcta puede, aun así, dejar el producto colgado casi un minuto. Seguridad y rendimiento se prueban juntos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(Cómo piensa el agente que escribe estos cuentos, con qué criterio decide qué contar y cómo, para que no sea una tirada de dados de un modelo de lenguaje, es tema para otro artículo.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Cifrar la PII de los peques no era "una feature de seguridad" más en el backlog: era ponerle un &lt;strong&gt;seguro de salud al producto y a la confianza&lt;/strong&gt; de quien lo usa. Y costó céntimos al mes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checklist de mentalidad (cripto de PII en serio):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Envelope, no cifrado directo contra el gestor de claves.&lt;/strong&gt; La clave maestra no sale de ahí; el cifrado masivo lo hace el cliente. Céntimos, no facturas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ata cada valor cifrado a su dueño y su campo.&lt;/strong&gt; Es prácticamente gratis y convierte cualquier swap de datos en un fallo de verificación.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Que el servidor rechace el texto en claro.&lt;/strong&gt; Las reglas de acceso son parte del esquema, no un adorno.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto-shredding = derecho al olvido operativo.&lt;/strong&gt; Borrar una clave es instantáneo y alcanza las copias de seguridad. Borrar datos a mano, no.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enumera tus huecos.&lt;/strong&gt; "No es de extremo a extremo" escrito no es debilidad: es la diferencia entre un sistema seguro y uno opaco.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prueba seguridad y rendimiento juntos.&lt;/strong&gt; Una comprobación de integridad correcta te puede dejar el producto colgado casi un minuto. Pregúntame cómo lo sé.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Y una última cosa. Nadie nos obligó a esto: ni un cliente, ni una auditoría externa que nos llegara de sorpresa. Pero si vas a pedirle a una familia que te cuente lo más íntimo de su hijo, lo mínimo es tratarlo como lo que es. Monta tu envelope, ata tus datos cifrados a su dueño, destruye la llave al borrar la cuenta… y &lt;strong&gt;duerme tranquilo&lt;/strong&gt; sabiendo que lo de tus usuarios está cifrado de verdad. Si le encuentras un fallo a lo nuestro, escríbeme: la versión honesta de la seguridad incluye que alguien de fuera te diga dónde te equivocaste. &lt;strong&gt;Tu yo del futuro, y el padre que confió en ti, te lo agradecerán.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>angular</category>
      <category>firebase</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
