wandres.dev
SEGURIDAD Y PRIVACIDAD · permisos y datos

Permisos del sistema: contexto, descripciones de uso y el no del usuario

Un permiso en iOS no es una condición que tu código evalúa: es una decisión que otra persona toma una sola vez, en un instante que tú eliges y con la única información que tú le diste. Esta lección desmonta el subsistema de consentimiento del sistema, explica por qué la cadena de uso obligatoria es un contrato revisable y no un texto decorativo, y ordena la única estrategia que sobrevive al escenario más probable de todos: que el usuario diga que no.

⏱ 17 min

Existe una forma cómoda de programar permisos y es exactamente la que produce las peores apps: tratarlos como una comprobación booleana que se resuelve al arrancar, en un bucle de peticiones encadenadas que el usuario despacha a base de rechazos. La forma exacta es otra. Un permiso es el resultado de una negociación asíncrona con un tercero soberano —la persona— mediada por un subsistema del sistema operativo que guarda la respuesta fuera de tu proceso, que no te deja volver a preguntar y que puede revocarla mientras tú no miras. Tu código no decide nada: solo elige el instante en que se plantea la pregunta y redacta la única frase que acompaña a la decisión. Todo lo demás —el retraso, la irreversibilidad, la revocación silenciosa— es una propiedad del sistema con la que hay que diseñar, no un obstáculo que sortear.

🎯 Al terminar esta lección sabrás
  • Entender el subsistema de consentimiento del sistema, dónde vive la respuesta y por qué no se puede reabrir.
  • Redactar cadenas de uso que superen la revisión y que además funcionen como interfaz.
  • Diseñar la petición para que llegue en el momento en que el permiso ya significa algo para el usuario.
  • Tratar el rechazo como un estado de producto de primera clase y no como una ruta de error.

Quién concede realmente el permiso

El subsistema que gobierna esto se llama TCC, de transparencia, consentimiento y control, y conviene visualizar dónde vive porque explica casi todo su comportamiento. La respuesta del usuario no se guarda en tu contenedor ni en UserDefaults: se guarda en una base de datos del sistema, indexada por identificador de paquete y por servicio protegido, fuera del alcance de tu proceso. De ahí se derivan tres consecuencias que sorprenden a quien viene de otras plataformas. La primera es que tú no puedes leer ni escribir esa decisión, solo consultarla a través de la API del framework correspondiente. La segunda es que sobrevive a la actualización de la app y desaparece al desinstalarla, motivo por el cual las pruebas manuales exigen borrar la app o reiniciar los avisos de privacidad desde los ajustes del dispositivo. Y la tercera es que la revocación ocurre en un proceso ajeno mientras tu app está suspendida, así que el estado que memorizaste al arrancar puede ser mentira cuando lo uses.

La segunda pieza es la cadena de uso. Cada servicio protegido tiene una clave asociada que debe existir en el Info.plist del objetivo antes de que se pueda solicitar el acceso: NSCameraUsageDescription para la cámara, NSMicrophoneUsageDescription para el micrófono, NSPhotoLibraryUsageDescription para leer la fototeca y NSPhotoLibraryAddUsageDescription para solo añadir, NSLocationWhenInUseUsageDescription y su variante permanente para la ubicación, NSContactsUsageDescription, NSCalendarsFullAccessUsageDescription, NSFaceIDUsageDescription, NSLocalNetworkUsageDescription, NSUserTrackingUsageDescription. Si la clave falta, no obtienes un aviso ni un rechazo elegante: el sistema termina el proceso en el instante exacto de la petición. Es un fallo de configuración que se manifiesta como un cierre inesperado, y por eso aparece casi siempre en producción y casi nunca en el simulador del desarrollador que ya tenía la clave puesta.

<key>NSCameraUsageDescription</key>
<string>Usamos la camara para escanear el codigo de barras de un producto y anadirlo a tu inventario sin teclear nada.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Con tu ubicacion mostramos primero las tiendas que tienes cerca. Sin ella tendras que buscarlas por nombre.</string>

Esas frases no son documentación interna: son la interfaz de la decisión. Aparecen literalmente en el cuadro de diálogo del sistema, debajo del título, y son la única información con la que cuenta el usuario. La revisión de la App Store las lee y rechaza las genéricas —el clásico texto que dice que la app necesita acceso a la cámara— por no explicar el uso concreto. Hay tres criterios que separan una cadena que funciona de una que no: nombra la funcionalidad concreta en lugar del recurso técnico, describe el beneficio en la primera línea porque el resto puede quedar truncado, y evita prometer lo que no cumples porque una cadena que dice que la ubicación no sale del dispositivo y un manifiesto de privacidad que declara lo contrario es una contradicción que la revisión detecta. Además deben localizarse: viven en el catálogo de cadenas del Info.plist, no en el archivo general de la app.

⚠️
La clave ausente no avisa, mata el proceso

Un objetivo de extensión, un paquete de pruebas de interfaz o un objetivo secundario creado a mano no heredan el Info.plist de la app principal. El fallo típico es una extensión de compartir o un App Clip que pide la cámara sin declarar la clave y se cierra en el primer uso real. Verifícalo en el archivo generado del artefacto compilado, no en la pestaña del editor.

Pedir cuando la respuesta ya tiene sentido

Ordenado el mecanismo, queda la decisión de diseño que determina el resultado: cuándo se pregunta. El patrón que hunde las tasas de aceptación es la ráfaga inicial, esa secuencia de cuatro diálogos que aparece antes de que el usuario haya visto una sola pantalla útil. En ese instante la persona no tiene modelo mental de la app, no sabe qué hace con la cámara y aplica la heurística que le ha enseñado el resto de la industria: negar todo lo que llega sin motivo. El patrón que funciona es el opuesto y tiene nombre propio, petición justo a tiempo: el permiso se solicita en el momento en que el usuario acaba de pedir la funcionalidad que lo necesita, cuando la pregunta del sistema se lee como la continuación natural de lo que él mismo inició.

Entre la acción del usuario y el diálogo del sistema cabe una pantalla propia de contexto, lo que se conoce como preaviso o preparación. Su función es doble y conviene entender la segunda porque es la importante: explica el porqué con tus palabras, tu tipografía y tu ilustración, pero sobre todo absorbe el no. Si el usuario rechaza tu pantalla, el diálogo del sistema nunca llega a mostrarse, el estado sigue en indeterminado y podrás volver a proponerlo más adelante, cuando el valor sea evidente. Si el rechazo cae directamente sobre el diálogo del sistema, has quemado la única bala que tenías. El preaviso es, literalmente, un mecanismo para no gastar el intento.

🎯

Justo a tiempo

El diálogo aparece como consecuencia de una acción del usuario: pulsa el botón de escanear y entonces se pide la cámara. La pregunta llega con su respuesta ya construida en la cabeza de quien decide.

🧭

Preaviso propio

Una pantalla tuya explica el beneficio antes del diálogo del sistema. Rechazarla no consume el intento, así que puedes reintentar la conversación cuando el contexto sea mejor.

🚪

Sin permiso

El selector de fotos, el importador de documentos o el botón de ubicación puntual devuelven el dato sin pedir nada, porque la selección ocurre fuera de tu proceso. Es la opción que siempre gana.

Ese tercer camino merece detenerse porque es el que más ha cambiado la práctica en los últimos años y el que muchos equipos siguen ignorando. El sistema ofrece un conjunto creciente de intermediarios fuera de proceso que entregan exactamente el dato que el usuario elige, sin conceder acceso al almacén completo y sin mostrar diálogo alguno: el selector de fotos moderno de SwiftUI, el importador de archivos, el selector de contactos, el botón de ubicación de una sola vez. La regla de decisión es directa y ahorra trabajo, revisión y manifiesto: el permiso más barato es el que no se pide, y solo se justifica solicitar acceso completo cuando la funcionalidad exige un acceso continuo o masivo que la selección puntual no puede cubrir, como una app de galería o una de mensajería que sincroniza la agenda.

import PhotosUI

// Sin NSPhotoLibraryUsageDescription y sin dialogo del sistema:
// el usuario elige fuera de proceso y solo tu recibes lo elegido.
struct Selector: View {
    @State private var item: PhotosPickerItem?
    var body: some View {
        PhotosPicker("Elegir foto", selection: $item, matching: .images)
    }
}

El no es un estado del producto

Queda la parte que casi nadie diseña y que decide la calidad percibida de la app: qué ocurre después de la negativa. Lo primero es abandonar la idea de que existen dos estados. Hay al menos cinco, y confundirlos produce interfaces que mienten. Indeterminado significa que aún no se ha preguntado y es el único desde el que el diálogo puede aparecer. Denegado significa que la persona dijo que no y que el sistema ya no volverá a preguntar por mucho que tú llames a la API. Autorizado es el caso feliz. Restringido significa que la decisión no es del usuario sino de una política de control parental o de gestión corporativa, y por tanto ni siquiera los ajustes lo resolverán. Y limitado, en fototeca y contactos, es un permiso parcial que muchas apps tratan erróneamente como denegado cuando en realidad es plenamente funcional para lo que necesitan.

stateDiagram-v2
[*] --> Indeterminado
Indeterminado --> Preaviso: el usuario pide la funcion
Preaviso --> Indeterminado: rechaza tu pantalla
Preaviso --> Dialogo: acepta seguir
Dialogo --> Autorizado
Dialogo --> Denegado
Denegado --> Ajustes: enlace explicito
Ajustes --> Autorizado: el usuario lo cambia
Autorizado --> Denegado: revocacion externa

De ese diagrama se deduce la regla operativa más importante de la lección: consulta el estado en el punto de uso, nunca en el arranque. El usuario puede revocar el micrófono desde los ajustes mientras tu app duerme en segundo plano, y volver a ella sin reiniciarla. Una app que memorizó el estado al lanzarse mostrará una interfaz activa que fallará al pulsarla, sin explicación. Además, la revocación de ciertos servicios provoca la terminación del proceso por parte del sistema, de modo que ese camino tampoco es uniforme y no conviene apoyarse en él.

import AVFoundation

func prepararCamara() async -> Bool {
    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .authorized:      return true
    case .notDetermined:   return await AVCaptureDevice.requestAccess(for: .video)
    case .denied, .restricted: return false   // no reintentes: el sistema ya no pregunta
    @unknown default:      return false
    }
}

Ante el estado denegado hay una única respuesta correcta y dos incorrectas muy extendidas. Las incorrectas son insistir con llamadas repetidas a la petición, que no producen diálogo alguno y solo desperdician ciclos, y bloquear la app con un muro que exige el permiso para continuar, algo que además choca con las directrices de revisión salvo cuando el permiso es constitutivo de la app. La correcta es degradar con dignidad: ofrecer el camino alternativo cuando existe, explicar en una línea qué se pierde y colocar un enlace directo a la pantalla de ajustes de la app mediante la URL de configuración que expone el sistema. Ese enlace lleva a la ficha de la app, no a la raíz de ajustes, y es la única vía legítima de recuperación.

💡
Notificaciones tienen una salida que las demás no tienen

Las notificaciones admiten autorización provisional: se conceden sin diálogo, se entregan de forma silenciosa al centro de notificaciones y el propio aviso ofrece al usuario mantenerlas o desactivarlas después de haberlas visto. Es el único permiso del sistema donde puedes demostrar el valor antes de pedir la decisión, y convierte una pregunta abstracta en una valoración informada.

Los permisos que no siguen el patrón general

Descrito el esquema común, conviene catalogar las excepciones, porque tratarlas como si fueran uniformes produce interfaces incorrectas incluso en apps por lo demás cuidadas. La ubicación es la más rica de todas: no tiene un permiso sino un retículo de decisiones. Existe el alcance —mientras se usa la app frente a siempre—, existe la precisión —completa frente a aproximada, donde el usuario puede conceder acceso reduciendo deliberadamente la exactitud a un radio de varios kilómetros— y existe una opción temporal que autoriza solo por esta vez y vuelve al estado indeterminado en la siguiente sesión. Un código que trata la ubicación como un sí o un no fallará en al menos tres escenarios legítimos: el usuario que concede con precisión reducida y ve una app que insiste en pedirle lo que ya dio, el que concede por una vez y recibe un diálogo cada apertura sin explicación, y el que concede mientras se usa a una app que necesitaba el alcance permanente y jamás se lo pidió con contexto.

La fototeca tiene su propia forma de permiso parcial: el usuario puede autorizar el acceso solo a una selección de imágenes que él mismo elige, y ese estado limitado es plenamente funcional, no una versión degradada. El error habitual consiste en detectarlo y mostrar un aviso pidiendo el acceso completo, cuando la conducta correcta es trabajar con lo concedido y ofrecer, si acaso, la posibilidad de ampliar la selección mediante la interfaz que el propio sistema proporciona para ello. Los contactos siguen un camino análogo con acceso limitado y un botón de selección incremental, y el calendario distingue desde hace varias versiones entre escritura de eventos nuevos y acceso completo, lo que permite a muchas apps bajar de nivel y pedir solo lo que necesitan.

import CoreLocation

func evaluar(_ gestor: CLLocationManager) -> String {
    switch gestor.authorizationStatus {
    case .authorizedAlways:    return "alcance permanente"
    case .authorizedWhenInUse:
        // precision reducida: no es un rechazo, es una concesion distinta
        return gestor.accuracyAuthorization == .fullAccuracy ? "completa" : "aproximada"
    case .denied, .restricted:  return "sin acceso"
    case .notDetermined:        return "pendiente"
    @unknown default:           return "desconocido"
    }
}

Hay dos permisos más que rompen el molde por motivos distintos y conviene conocerlos. La red local, exigida desde hace varias versiones para cualquier descubrimiento de servicios o comunicación directa con dispositivos de la misma red, se pide de forma implícita al primer intento de descubrimiento y, si se deniega, no produce error: devuelve una lista vacía indistinguible de una red sin dispositivos, lo que convierte su depuración en un ejercicio de paciencia. Y el uso de la biometría requiere su cadena de uso propia aunque el sistema no muestre un diálogo de concesión al estilo habitual, porque la autorización se resuelve dentro de la propia evaluación biométrica; olvidar esa clave produce el mismo cierre inesperado que cualquier otra.

ℹ️
El alcance permanente tiene su propio ritual

La ubicación en segundo plano no se concede en un solo paso: primero se obtiene el alcance mientras se usa la app y, más tarde, el sistema puede ofrecer al usuario ampliarlo, a menudo acompañando la pregunta con un mapa de los lugares donde la app consultó su posición. Diseñar la funcionalidad asumiendo que el permiso permanente llegará al primer intento es garantizar una experiencia rota durante los primeros días de uso.

Un permiso no es un dato: es una relación con un estado y una historia

El salto conceptual que separa a un equipo que pide permisos de uno que los entiende cabe en una reformulación. Tú no consultas un booleano: participas en una máquina de estados compartida entre tres agentes —tu proceso, el subsistema de consentimiento y una persona— donde tú solo controlas dos entradas, el instante de la pregunta y la frase que la acompaña, y ninguna de las salidas. Esa asimetría lo explica todo. Explica por qué el estado debe leerse siempre en el borde de uso y jamás cachearse, porque otro agente lo modifica sin avisarte. Explica por qué la irreversibilidad convierte cada petición en un recurso agotable que hay que gastar con criterio, y por qué el preaviso propio no es cortesía sino gestión de ese recurso. Explica por qué la cadena de uso es la pieza más importante de toda la funcionalidad: es el único canal de comunicación que tienes dentro de una interfaz que no controlas, revisada por un tercero, presentada en un momento de máxima desconfianza, y de su redacción depende un porcentaje de conversión que ningún cambio posterior de código va a recuperar. Y explica, finalmente, por qué la mejor arquitectura de permisos es la que reduce su número: cada intermediario fuera de proceso que adoptas elimina un estado de la máquina, una clave del manifiesto, una frase que redactar, una fila del cuestionario de privacidad y una vía de rechazo. La sofisticación aquí no consiste en pedir mejor, sino en necesitar menos.

📝
Lo esencial

La respuesta vive en el subsistema TCC, fuera de tu proceso, y solo se reinicia al desinstalar. La clave de uso debe existir en el Info.plist del objetivo que pide o el sistema termina la app; su texto es interfaz y lo revisa Apple. Pide justo a tiempo, protege el intento con un preaviso propio y prefiere siempre los selectores fuera de proceso. Hay cinco estados, no dos: consúltalos en el punto de uso y trata el rechazo como una rama de producto con enlace a ajustes, nunca como un error.

⚔️ Auditar la superficie de consentimiento
  1. Extrae del artefacto compilado la lista completa de claves de uso declaradas, incluidas las de extensiones, y marca cuáles se piden realmente en algún camino del código: las huérfanas son deuda y bandera roja en revisión.
  2. Reescribe las tres cadenas más genéricas siguiendo los tres criterios de la lección y comprueba en dispositivo que la primera línea no queda truncada en el diálogo.
  3. Localiza en tu base de código todo lugar donde el estado de un permiso se lea al arrancar y se guarde en una propiedad; muévelo al punto de uso y documenta por qué.
  4. Sustituye una petición de acceso a fototeca o contactos por el selector fuera de proceso equivalente y mide cuántas claves, estados y ramas de interfaz desaparecen con el cambio.
  5. Diseña e implementa la pantalla de rechazo de un permiso crítico: qué se pierde, qué alternativa queda y el enlace a los ajustes de la app; después revoca el permiso desde ajustes con la app en segundo plano y verifica que la interfaz se corrige sola al volver.