wandres.dev
PERMISOS Y PRIVACIDAD · el contrato con el usuario

El modelo de permisos

Un permiso no es un interruptor: es un contrato entre tres partes que no se conocen. Esta lección reconstruye el modelo completo: los niveles de protección y quién concede cada uno, la frontera entre lo que se otorga al instalar y lo que se negocia en tiempo de ejecución, los grupos de permisos y la granularidad real que percibe el usuario, el flujo canónico de solicitud con los contratos de resultado de actividad, y por qué desde la API 30 conviene entender cada concesión como un préstamo revocable y nunca como una propiedad adquirida.

⏱ 22 min

Durante los primeros seis años de Android, pedir permiso consistía en publicar una lista en el manifiesto y esperar a que alguien la aceptara entera en el momento de instalar. El modelo era honesto en su simplicidad y catastrófico en la práctica: la decisión se tomaba antes de haber usado la aplicación, sin contexto, con granularidad de todo o nada, y su resultado quedaba congelado para siempre. La API 23 rompió ese contrato y trasladó la negociación al instante exacto en que la funcionalidad se necesita, con la posibilidad de deshacerla después. Lo que parece un cambio de interfaz es en realidad un cambio de modelo computacional: a partir de ahí ninguna llamada protegida puede asumir que tendrá éxito, el estado de un permiso es una variable observable que muta fuera de tu proceso y sin avisarte, y tu código tiene que estar escrito para funcionar en ambos mundos a la vez. Casi todos los defectos de permisos que llegan a producción son consecuencia de no haber interiorizado esa única frase.

🎯 Al terminar esta lección sabrás
  • Distinguir los niveles de protección normal, dangerous y signature, y situar aparte los accesos especiales.
  • Explicar qué es un grupo de permisos y qué garantiza, y qué no garantiza, la concesión de uno de sus miembros.
  • Implementar el flujo canónico de solicitud con los contratos de resultado de actividad, comprobando siempre antes de pedir.
  • Razonar sobre la concesión como préstamo revocable: revocación manual, concesión de una sola vez y retirada automática por desuso.

Niveles de protección: quién concede y en qué momento

Todo permiso registrado en el sistema lleva un atributo protectionLevel que determina por completo su procedimiento de concesión. No es una etiqueta informativa: es el algoritmo. Entenderlo evita la confusión más común del principiante, que es preguntarse por qué unos permisos abren un diálogo y otros no.

Los de nivel normal los concede el instalador de paquetes sin intervención humana, porque el riesgo que representan para la privacidad o para el funcionamiento del dispositivo se consideró despreciable. Acceder a la red, vibrar, consultar el estado de la conectividad o fijar la alarma del despertador entran aquí. El usuario no los aprueba y tampoco puede revocarlos; solo puede verlos en la ficha de la aplicación. Basta con declararlos.

Los de nivel dangerous protegen datos o capacidades que pertenecen conceptualmente al usuario y no al dispositivo: sus contactos, su posición, su cámara, su micrófono, su calendario, sus mensajes, su cuerpo. Estos exigen consentimiento explícito, y ese consentimiento se pide con una llamada asíncrona cuyo resultado no controlas. Declararlos en el manifiesto es condición necesaria y absolutamente insuficiente.

Los de nivel signature se conceden únicamente si quien los pide está firmado con la misma clave que quien los declaró. Es el mecanismo con el que una familia de aplicaciones del mismo autor comparte datos sin molestar a nadie, y también el que usa el propio sistema para reservarse operaciones privilegiadas que ninguna aplicación de tienda podrá obtener jamás, por mucho que las declare.

🟢

Concesión automática

normal y signature. Se resuelven en el momento de la instalación con una comprobación puramente mecánica. Si el resultado es negativo, el permiso simplemente no está, y no hay diálogo que pueda cambiarlo.

🔐

Concesión negociada

dangerous y los accesos especiales. Requieren una decisión humana tomada en un momento concreto, con un contexto concreto, y revisable en cualquier instante posterior desde los ajustes del sistema.

Existe una cuarta familia que no cabe en el atributo y que conviene tratar por separado porque su flujo es distinto: los accesos especiales, gobernados por AppOpsManager. Dibujar encima de otras aplicaciones, gestionar todo el almacenamiento, instalar paquetes, programar alarmas exactas o leer estadísticas de uso no se piden con un diálogo modal, sino con un Intent que abre una pantalla de Ajustes desde la que el usuario activa un interruptor. No hay devolución de resultado fiable: hay que comprobar el estado al volver al primer plano.

<manifest>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission
        android:name="android.permission.READ_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />
</manifest>

El atributo maxSdkVersion de ese último ejemplo merece atención, porque es la herramienta con la que se declara un permiso obsoleto sin arrastrarlo a las versiones modernas del sistema. Sin él, tu ficha en la tienda anuncia capacidades que ya no ejerces, y los revisores de Google Play lo interpretan exactamente como lo que parece.

Grupos: la granularidad que ve el usuario

Los permisos peligrosos están organizados en grupos, y el sistema toma decisiones de interfaz sobre el grupo, no sobre el permiso individual. Históricamente esto llegó al extremo de conceder automáticamente el segundo miembro de un grupo cuando el primero ya estaba concedido, algo que producía la desconcertante sensación de haber obtenido un permiso que nunca se pidió.

Ese comportamiento se ha ido restringiendo versión a versión, y hoy la regla segura es exactamente la contraria: nunca asumas que la concesión de un permiso implica la de su compañero de grupo. Comprueba siempre cada permiso que vas a usar, individualmente, justo antes de usarlo. El coste de la comprobación es despreciable y la alternativa es una excepción de seguridad en el dispositivo de un usuario que no puedes reproducir.

Lo que sí sigue siendo cierto, y es lo que importa desde el punto de vista del diseño, es que el usuario razona por grupos. Nadie distingue entre leer y escribir el calendario: lo que la persona decide es si esta aplicación puede meterse en su calendario. Diseñar la solicitud pensando en la categoría mental del usuario y no en la constante de la API es lo que separa una petición que se acepta de una que se rechaza.

fun tieneAcceso(contexto: Context, permiso: String): Boolean =
    ContextCompat.checkSelfPermission(contexto, permiso) ==
        PackageManager.PERMISSION_GRANTED

Merece la pena señalar que el catálogo de permisos no es cerrado: cualquier aplicación puede declarar los suyos con el elemento permission y protegerlos con el nivel que elija. Es el mecanismo correcto cuando expones un proveedor de contenidos o un servicio a otras aplicaciones y quieres que solo las tuyas puedan alcanzarlo. Elegir el nivel signature en ese caso no es una preferencia estética sino la única opción defendible, porque cualquier otra convierte tu componente exportado en una superficie de ataque que depende de que el usuario lea un diálogo.

<permission
    android:name="com.ejemplo.permiso.LEER_PEDIDOS"
    android:protectionLevel="signature" />

<provider
    android:name=".PedidosProvider"
    android:authorities="com.ejemplo.pedidos"
    android:exported="true"
    android:readPermission="com.ejemplo.permiso.LEER_PEDIDOS" />

El error clásico en este terreno consiste en declarar un permiso propio con nivel normal creyendo que basta con que exista. Un permiso de nivel normal se concede a cualquiera que lo pida, incluida una aplicación maliciosa que copie literalmente la cadena de tu manifiesto, de modo que el resultado neto es un componente completamente abierto con la apariencia tranquilizadora de estar protegido.

El flujo canónico de solicitud

La secuencia correcta tiene cuatro pasos y ninguno es opcional. Primero se comprueba el estado actual, porque el permiso puede estar ya concedido y volver a pedirlo sería un diálogo gratuito. Segundo, si no lo está, se consulta si procede explicar. Tercero se lanza la solicitud. Cuarto se reacciona al resultado, que llega de forma asíncrona y en un momento en el que tu actividad pudo haber sido recreada.

Ese último detalle es la razón de que la API basada en onRequestPermissionsResult esté obsoleta y de que los contratos de resultado de actividad sean hoy la única forma correcta. El registro tiene que ocurrir durante la construcción del componente, antes de que exista la posibilidad de reanudarse, porque el sistema necesita poder entregar un resultado a una instancia que todavía no existía cuando se lanzó la petición.

Cuando la funcionalidad necesita varios permisos, el contrato de solicitud múltiple entrega un mapa con una entrada por permiso, y ese mapa hay que leerlo entrada por entrada. La tentación de comprobar si todos los valores son verdaderos, aunque parezca razonable, suele ser un error de diseño encubierto: si la funcionalidad requiere de verdad los tres permisos a la vez, probablemente esté haciendo tres cosas y convenga separarla antes que pedirlos juntos.

class EscanerActivity : ComponentActivity() {

    private val pedirCamara = registerForActivityResult(
        ActivityResultContracts.RequestPermission(),
    ) { concedido ->
        if (concedido) abrirVisor() else ofrecerAlternativa()
    }

    private fun alPulsarEscanear() {
        val permiso = Manifest.permission.CAMERA
        when {
            tieneAcceso(this, permiso) -> abrirVisor()
            shouldShowRequestPermissionRationale(permiso) ->
                explicarYLuego { pedirCamara.launch(permiso) }
            else -> pedirCamara.launch(permiso)
        }
    }
}
flowchart TD
A[El usuario pulsa la funcion] --> B[Comprobar estado actual]
B -->|concedido| C[Ejecutar la funcion]
B -->|no concedido| D[Consultar si procede explicar]
D -->|si| E[Mostrar explicacion en contexto]
D -->|no| F[Lanzar el dialogo del sistema]
E --> F
F --> G[Resultado asincrono]
G -->|concedido| C
G -->|denegado| H[Degradar con dignidad]
💡
Pide en el punto de uso, nunca al arrancar

La tasa de aceptación de un permiso está determinada casi por completo por el contexto en que se solicita. Un diálogo de cámara que aparece porque el usuario acaba de pulsar el botón de escanear un código se acepta de forma abrumadora; el mismo diálogo mostrado en la tercera pantalla de bienvenida, antes de que nadie sepa para qué sirve la aplicación, se rechaza de forma igualmente abrumadora. Y ese rechazo no es reversible en la práctica, porque el usuario no volverá a Ajustes a corregirlo. Pedir pronto no adelanta trabajo: lo destruye.

Un permiso es un préstamo, no una propiedad

La plataforma lleva varias versiones erosionando deliberadamente la idea de concesión permanente, y conviene leer esa erosión como una dirección de diseño y no como una serie de incidencias. Desde la API 29 existe la concesión de una sola vez para cámara, micrófono y ubicación, que caduca cuando la aplicación deja de estar en primer plano. Desde la API 30, el sistema retira automáticamente los permisos de las aplicaciones que llevan meses sin abrirse. Y el usuario puede revocar cualquier permiso peligroso en cualquier momento, lo que provoca la terminación del proceso para garantizar que ningún estado en memoria sobreviva a la decisión.

La consecuencia arquitectónica es que el estado del permiso no se puede cachear entre sesiones ni almacenar en una preferencia. La única fuente de verdad es la comprobación inmediata anterior al uso. Cualquier variable propia que pretenda recordar que ya se concedió está garantizada a mentir tarde o temprano.

En la dirección contraria, existe la posibilidad de devolver lo que ya no necesitas. Es una operación infrecuente y muy poco conocida, y es exactamente lo que un usuario técnico espera de una aplicación seria: si una funcionalidad se retira o el usuario la desactiva, el permiso que la sostenía debería desaparecer con ella.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    context.revokeSelfPermissionOnKill(Manifest.permission.RECORD_AUDIO)
}
El permiso no es una llave: es una promesa que alguien puede romper

Hay una manera de mirar el sistema de permisos que reorganiza todo lo demás, y consiste en dejar de pensarlo como un mecanismo de control de acceso para pensarlo como un protocolo de confianza con estado externo y adversario benigno. Un control de acceso clásico responde a la pregunta de si un sujeto puede ejecutar una operación, y su respuesta es una función del sujeto y del recurso; en Android la respuesta depende además de una tercera entidad —una persona— que no está obligada a ser coherente, que puede cambiar de opinión mientras tu código se ejecuta, que no lee, que decide en menos de dos segundos y a la que el sistema anima activamente a decir que no. Programar contra ese modelo con la mentalidad del control de acceso clásico produce exactamente el género de código que falla en producción y no falla nunca en el escritorio del desarrollador: comprobaciones al arrancar cuyo resultado se guarda en un campo, rutas felices que asumen la concesión porque el diálogo se aceptó hace diez minutos, y estados imposibles que solo aparecen cuando alguien abre Ajustes con la aplicación viva. La disciplina correcta se deriva de una sola premisa: cada llamada protegida es un punto de fallo esperado y recuperable, igual que una petición de red. Nadie escribe una petición HTTP sin ruta de error porque nadie cree que la red sea fiable; el permiso merece exactamente el mismo respeto epistemológico, porque su fiabilidad es menor. De esa premisa salen todas las prácticas correctas sin necesidad de memorizarlas: se comprueba justo antes de usar, no se cachea, se pide en el punto de uso y no en el arranque, se explica antes de pedir cuando el sistema dice que procede, y se diseña la funcionalidad de modo que la denegación produzca una experiencia menor pero coherente en lugar de un callejón sin salida. Y hay un corolario más incómodo que casi nadie extrae: si tu aplicación no puede hacer absolutamente nada sin un permiso, el problema no está en el diálogo ni en el texto de la explicación. Está en que has diseñado un producto que exige confianza antes de haberla ganado, y ningún flujo de solicitud, por elegante que sea, arregla eso.

⚔️ Audita tu superficie de permisos
  1. Lista todos los uses-permission de tu manifiesto y clasifica cada uno por nivel de protección; anota cuántos no sabrías justificar ante un revisor.
  2. Localiza cada permiso peligroso en el código y comprueba si la solicitud ocurre en el punto de uso o durante el arranque.
  3. Busca cualquier variable, preferencia o campo que guarde el estado de un permiso y elimínalo, sustituyéndolo por una comprobación inmediata.
  4. Revoca un permiso desde Ajustes con la aplicación abierta y documenta qué le ocurre a tu proceso y a tu estado en memoria.
  5. Elige un permiso que solo use una funcionalidad opcional y añade su devolución explícita cuando esa funcionalidad se desactive.