Gestión de excepciones de Acceso Condicional para empleados en desplazamiento internacional. Dos modos de operación: aprobación explícita con MSAL delegado y Protected Actions, o autoservicio con token daemon.
La política de Acceso Condicional "Risky Countries" bloquea el acceso a recursos corporativos desde países considerados de riesgo. Cuando un empleado viaja a uno de esos países por motivos de trabajo, necesita una excepción temporal, controlada y completamente reversible.
La política principal Risky Countries - 01 - Default bloquea cualquier intento de autenticación desde la Named Location #04 - Risky Countries. Si un empleado viaja a uno de esos países, pierde acceso a correo, Teams, SharePoint y cualquier recurso protegido por Entra ID. No existe mecanismo nativo en la política para hacer excepciones puntuales sin modificar objetos compartidos por todo el tenant.
Esta aplicación crea una excepción temporal de mínimo privilegio por usuario: añade al viajero al grupo de exclusiones de la política principal, y crea objetos CA específicos para su viaje (Named Location + política CA) que le permiten acceder únicamente desde su país de destino y únicamente hasta su fecha de vuelta. Dos modos: con aprobación (MSAL delegado + Protected Actions + ticket SIS en Jira) o autoservicio (token daemon + ejecución directa, sin ticket SIS). El toggle se cambia en la BD SQLite sin reiniciar el servicio.
Risky Countries - 01 - Default
9d0adb2b-ca25-4278-80ec-c627b3611bf8
#04 - Risky Countries — lista de países bloqueados
1f753369-b550-4df1-9811-60709007b8c6
CA - Travel Exceptions — excludeGroups en la política principal
9ef82868-2d3f-4abd-96eb-cfbc3783addc
Antes de esta herramienta el proceso era enteramente manual en el portal de Entra ID. Cada excepción requería al menos un sysadmin con permisos de Acceso Condicional, sin posibilidad de delegación al helpdesk.
Acceso al portal de Entra ID → crear Named Location → esperar propagación → crear CA policy → añadir al grupo. Proceso inverso al volver. Solo ejecutable por sysadmins con permisos Policy.ReadWrite.ConditionalAccess. Sin trazabilidad automática ni recordatorio de desactivación.
Con aprobación: Helpdesk rellena el formulario (2 min) → ticket Jira creado → admin abre enlace, MFA y confirma (3 min) → app ejecuta las 4 operaciones Graph. Autoservicio: Helpdesk rellena el formulario → app ejecuta directamente con token daemon, sin intervención de admin. Vencimiento detectado automáticamente por el scheduler.
El scheduler detecta automáticamente las excepciones vencidas y notifica para su desactivación. Antes, una excepción podía quedar activa indefinidamente si nadie recordaba desactivarla, ampliando la superficie de ataque del tenant.
Cada excepción queda registrada en SQLite con policy_id, location_id, approved_by y timestamps. Jira recibe comentarios automáticos en cada evento (aprobación, desactivación, cancelación). La etiqueta Travel-CA-Exception facilita búsquedas futuras.
El helpdesk puede crear y hacer seguimiento de solicitudes sin necesidad de permisos Policy.ReadWrite.ConditionalAccess ni acceso al portal de Entra ID. El rol Approver, reservado a sysadmins, es el único que ejecuta operaciones sobre Graph.
El coste operativo por excepción adicional es prácticamente nulo. Picos de solicitudes (periodos vacacionales, eventos internacionales) no generan carga proporcional en el equipo de sistemas.
Cinco componentes de Entra ID se coordinan para crear una excepción de mínimo privilegio: el usuario solo puede acceder desde su país de destino exacto, y solo durante el periodo aprobado.
| Componente | Identificador / Patrón de nombre | Descripción | Rol en la excepción |
|---|---|---|---|
| Política principal Risky Countries - 01 - Default |
9d0adb2b-ca25-4278… |
Bloquea acceso desde Named Location #04 a todos los usuarios del tenant | Es la política que se evita mediante el grupo excludeGroups |
| Named Location #04 #04 - Risky Countries |
1f753369-b550-4df1… |
Lista de países de riesgo. Origen bloqueado por la política principal | Condición de ubicación de la política principal; permanente, no se modifica nunca |
| Grupo de excepciones CA - Travel Exceptions |
9ef82868-2d3f-4abd… |
Grupo configurado como excludeGroups en la política principal |
Los miembros de este grupo quedan excluidos del bloqueo general; la app los añade y elimina |
| Named Location temporal Creada por la app |
Travel | DisplayName | countries | hasta YYYY-MM-DD |
Contiene únicamente el país (o países) de destino del viajero | Referenciada en la política CA temporal como única ubicación permitida |
| Política CA temporal Creada por la app |
Travel | UPN | countries | hasta YYYY-MM-DD |
Política específica para el usuario viajero; bloquea si el acceso no proviene de su Named Location de viaje | Restringe al usuario a acceder únicamente desde su país de destino; eliminada al deshabilitar |
La excepción combina dos mecanismos complementarios. Primero, el usuario entra en el grupo excludeGroups de la política principal — esto elimina el bloqueo genérico por país de riesgo. Sin más cambios, el usuario podría acceder desde cualquier país, incluyendo otros de riesgo ajenos a su viaje. Para evitarlo, la política CA temporal añade una restricción positiva: el usuario solo puede autenticarse si su IP coincide con su Named Location de viaje. El resultado es acceso de mínimo privilegio: exactamente desde el país de destino, exactamente durante el periodo aprobado.
Al deshabilitar una excepción (manual o automáticamente por el scheduler), la app ejecuta tres operaciones de borrado en Graph: elimina la política CA temporal (DELETE /policies/conditionalAccessPolicies/{id}), elimina la Named Location temporal (DELETE /identity/conditionalAccess/namedLocations/{id}, con reintentos para el error 1178) y elimina la membresía del usuario en el grupo de excepciones. El tenant vuelve exactamente al estado anterior sin rastro en objetos CA compartidos.
La app está en el puerto 8091, desplegada con WinSW en el servidor sisapps y accesible vía App Proxy en https://travelcaapprover-idealistaa82505660.msappproxy.net.
/request — rellena el formulario: UPN del viajero (autocompletar desde el grupo "Domain internal users", caché de 5 minutos), países de destino (selector multi-país con 250 códigos ISO), fecha de vuelta y referencia de incidencia Jira (incidencia_ref)pending, genera un UUID token de aprobación con expiración de 7 díasincidencia_ref (link tipo "Relates") y añade la etiqueta Travel-CA-Exception a ambos tickets/approve/{token} de la app/auth/start → la app inicia un MSAL auth code flow con client_capabilities=["CP1"], redirigiendo al admin a Entra ID para autenticación delegadaacrs) — la app intenta las operaciones Graph sobre políticas CA y recibe 401 Unauthorized con un claims challenge codificado en el cuerpo JSON de la respuestaclaims del JSON, lo almacena en sesión Flask y redirige al admin de vuelta a Entra ID con el parámetro claims= en la URL de autorizaciónacrs=c1, satisfaciendo el requisito de Protected Actions de la política CA #0002/exec-approve ejecuta las 4 operaciones Graph — (1) crea Named Location temporal, (2) espera propagación con reintentos, (3) crea política CA temporal, (4) añade el usuario al grupo CA – Travel Exceptionspolicy_id, location_id y approved_by para poder revertirla despuésadd_approval_comment en la incidencia_ref del helpdesk Y en la tarea SIS, con tabla wiki: UPN, países, fecha fin, policy_id, location_id, approved_by/request-disable genera un disable token (expira en 14 días) y presenta la página de confirmación con detalle de la excepciónacrs=c1disabled en SQLite — se registra quién la desactivó y el timestamp exactoadd_disable_comment — comentario en la incidencia original del helpdesk Y en la tarea SIS de activación, con resumen de la desactivaciónAPScheduler ejecuta una comprobación diaria a las 08:00 (Europe/Madrid) para detectar excepciones vencidas. El comportamiento depende del toggle approval_required en la BD.
approval_required = false)El scheduler aplica las desactivaciones directamente sin intervención humana:
250c7cb4, certificado)graph.disable() → marca disabled en SQLite → add_disable_comment solo en incidencia_ref (sin ticket SIS)approval_required = true)El scheduler no aplica las desactivaciones — notifica para que un admin las ejecute con su MFA:
Travel-CA-ExceptionEl dashboard incluye un botón "Revisar vencidas" en la cabecera que llama a run_now() del scheduler. Útil para forzar la comprobación sin esperar al horario automático, por ejemplo tras un reinicio del servicio. Ejecuta el mismo flujo condicional según el toggle activo en ese momento.
"scheduler": {
"enabled": true,
"hour": 8,
"minute": 0,
"timezone": "Europe/Madrid"
}
Todos los eventos del ciclo de vida de una excepción generan actividad en Jira. La integración se puede deshabilitar globalmente con jira.enabled: false.
| Operación | Momento | Qué hace | Tickets afectados | Modo |
|---|---|---|---|---|
create_enable_ticket |
Al crear la solicitud | Crea tarea SIS con URL de aprobación en descripción | Nuevo SIS + link "Relates" a incidencia_ref + etiqueta en ambos |
Con aprobación |
add_approval_comment |
Al aprobar la excepción | Comentario wiki con tabla de detalles: UPN, países, fecha fin, policy_id, location_id, approved_by | incidencia_ref + tarea SIS (con aprob.) / solo incidencia_ref (autoservicio) |
Ambos |
add_disable_comment |
Al deshabilitar (manual o scheduler) | Comentario con resumen de desactivación y quién la ejecutó (o [scheduler]) |
Con aprobación: incidencia_ref + tarea SIS / Autoservicio: solo incidencia_ref |
Ambos |
add_cancel_comment |
Al cancelar una solicitud pendiente | Comentario indicando que la solicitud fue cancelada sin llegar a activarse | Solo incidencia_ref del helpdesk |
Ambos |
create_expiry_ticket |
Scheduler diario (excepciones vencidas) | Una única tarea SIS con tabla wiki de todas las vencidas ese día y enlace de desactivación por cada una | Nuevo ticket bulk + link_issues a cada SIS de activación + etiqueta |
Con aprobación |
"jira": {
"enabled": true,
"base_url": "https://idealista.atlassian.net",
"user_email": "[email protected]",
"api_token": "<JIRA_API_TOKEN>",
"project_key": "SIS"
}
Tres capas de autenticación: App Proxy Entra ID para SSO corporativo, MSAL auth code flow con claims challenge para operaciones Protected Actions (modo con aprobación), y token daemon vía SisScripts para ejecución directa (modo autoservicio).
X-MS-CLIENT-PRINCIPAL-NAMEhttps://travelcaapprover-idealistaa82505660.msappproxy.nethttp://localhost:80916a0be082-1ff1-45ab-ad23-16dfc08db20fDos roles definidos en la App Registration y asignados en Enterprise App. Obtenidos vía GET /servicePrincipals/{sp_id}/appRoleAssignedTo y cacheados en sesión Flask.
| Rol | Audiencia | Acceso |
|---|---|---|
| Requester | Helpdesk | Crear solicitudes, ver estado propio |
| Approver | Sysadmin | Aprobar, deshabilitar, dashboard completo, trigger scheduler |
250c7cb4)En modo autoservicio, tanto las activaciones/desactivaciones manuales como el scheduler usan un token daemon (client credentials) de la App Registration compartida SisScripts. La sección daemon_graph en app-config.json apunta a este cliente; si no existe, se usa la sección auth como fallback.
| Campo | Valor |
|---|---|
| Client ID | 250c7cb4-… (SisScripts App Registration) |
| Auth method | Certificado — thumbprint FC402921656C6BFE606980EB654C7A507883239E, leído de CurrentUser\My vía PowerShell subprocess |
| Permisos Application en SisScripts | Policy.ReadWrite.ConditionalAccess, Policy.Read.All, Group.ReadWrite.All, User.Read.All |
| Permiso | Tipo | App Registration | Para qué |
|---|---|---|---|
Policy.ReadWrite.ConditionalAccess |
Delegated | TravelCAApprover | Crear y eliminar políticas CA y Named Locations (Protected Actions) |
Policy.Read.All |
Delegated | TravelCAApprover | Leer políticas existentes para verificación |
Group.ReadWrite.All |
Delegated | TravelCAApprover | Añadir y eliminar miembros del grupo CA – Travel Exceptions |
User.Read.All |
Delegated | TravelCAApprover | Resolver UPN a displayName para construir nombres de los objetos CA |
User.Read.All |
Application | TravelCAApprover | Obtener perfiles de usuario para autocompletar (daemon) |
AppRoleAssignment.ReadWrite.All |
Application | TravelCAApprover | Leer assignments de roles de la app para autorización (daemon) |
GroupMember.Read.All |
Application | TravelCAApprover | Listar miembros del grupo "Domain internal users" para autocompletar UPN |
Policy.ReadWrite.ConditionalAccess |
Application | SisScripts (250c7cb4) |
Crear y eliminar políticas CA y Named Locations en modo autoservicio |
Policy.Read.All |
Application | SisScripts (250c7cb4) |
Leer políticas para Named Location lookup — requerido junto a ReadWrite |
Group.ReadWrite.All |
Application | SisScripts (250c7cb4) |
Gestionar membresía grupo CA – Travel Exceptions en modo autoservicio |
User.Read.All |
Application | SisScripts (250c7cb4) |
Resolver UPN a displayName para nombre de los objetos CA (daemon) |
Registro de los obstáculos técnicos encontrados durante el desarrollo y las soluciones aplicadas, como referencia para futuras intervenciones.
Síntoma
Todos los accesos devolvían 401. El header X-MS-CLIENT-PRINCIPAL-NAME no llegaba a Flask. En modo dev_bypass_upn funcionaba correctamente.
Causa raíz
App Proxy estaba configurado con pre-auth "Passthrough" en lugar de "Microsoft Entra ID". Sin pre-autenticación activa, Entra ID no inyecta los headers SSO.
Solución
Cambiar a pre-auth "Microsoft Entra ID" + SSO type "Header-based". La caché del navegador servía el 401 anterior — verificar siempre en ventana de incógnito tras cambios en App Proxy.
Síntoma
Un sysadmin con rol Approver correctamente asignado en la Enterprise App era tratado como Requester. El array de roles devuelto estaba vacío.
Causa raíz
GET /users/{upn}/appRoleAssignments pagina por defecto a 50 resultados. El usuario tenía más de 50 app role assignments en el tenant; el rol Approver de esta app quedaba en la página 2, que no se leía.
Solución
Cambiar a GET /servicePrincipals/{sp_id}/appRoleAssignedTo, que ya filtra por la app y devuelve solo los assignments de esta aplicación sin paginación problemática.
Síntoma
Al intentar filtrar los assignments por principalId usando OData $filter, Graph API devolvía 400 Bad Request.
Causa raíz
El endpoint /servicePrincipals/{id}/appRoleAssignedTo no soporta OData $filter en la versión actual de Microsoft Graph API.
Solución
Traer todos los assignments sin filtro y filtrar en Python por principalId == object_id_del_usuario.
Síntoma
La app no podía resolver el UPN del viajero a displayName para construir el nombre de la Named Location. Error 403 Forbidden con token daemon.
Causa raíz
Faltaba el permiso application User.Read.All en la App Registration. El permiso delegado del mismo nombre no cubre las llamadas realizadas con el token daemon (client credentials).
Solución
Añadir User.Read.All (tipo Application) en "API permissions" de la App Registration y aplicar admin consent del tenant.
Síntoma
Las llamadas Graph para crear y eliminar políticas CA fallaban con 401 o 403 aunque el token era válido. La política CA #0002 del tenant requiere el claim acrs=c1 en el token.
Causa raíz
Las Managed Identities no pueden satisfacer Protected Actions porque no existe usuario interactivo capaz de hacer MFA step-up. Se investigaron y descartaron: desactivar la política #0002, política CA para workload identities, Workload ID Premium.
Solución
MSAL auth code flow delegado con client_capabilities=["CP1"] + parsing del claims challenge devuelto por Graph + redirección con parámetro claims=. El admin completa el MFA step-up y el nuevo token incluye acrs=c1.
Síntoma
Al crear tickets Jira con la etiqueta "Travel CA Exception" (con espacios), la API devolvía 400 Bad Request. Los tickets se creaban sin etiqueta.
Causa raíz
La API de Jira Cloud no acepta etiquetas con espacios en blanco. Las etiquetas deben ser una sola cadena sin espacios.
Solución
Renombrar a Travel-CA-Exception (con guiones). Funciona correctamente en Jira y sigue siendo legible en los filtros y búsquedas.
Síntoma
El autocompletar de UPN en el formulario de nueva solicitud fallaba con 403 al intentar listar miembros del grupo "Domain internal users" con el token daemon.
Causa raíz
GET /groups/{id}/members con credenciales de aplicación requiere explícitamente GroupMember.Read.All. El permiso delegado Group.ReadWrite.All no cubre esta lectura en modo daemon.
Solución
Añadir GroupMember.Read.All (tipo Application) en la App Registration + admin consent. Se aplica caché de 5 minutos dada la tamaño del grupo.
Policy.Read.AllSíntoma
En modo autoservicio, la llamada para crear la Named Location fallaba con 403 usando el token daemon de SisScripts, aunque el permiso Policy.ReadWrite.ConditionalAccess estaba correctamente concedido con admin consent.
Causa raíz
Microsoft Graph requiere Policy.Read.All junto a Policy.ReadWrite.ConditionalAccess para las operaciones sobre Named Locations con credenciales de aplicación (Application type). Sin el permiso de lectura, el endpoint devuelve 403 incluso aunque el de escritura esté concedido.
Solución
Añadir Policy.Read.All tipo Application en la App Registration SisScripts + admin consent del tenant. Verificar siempre que los permisos Application tienen admin consent aplicado (no basta con añadirlos).
Síntoma
Restart-Service TravelCAApprover falló o dejó el servicio en estado inconsistente. El proceso Python seguía en memoria escuchando en el puerto 8091 bloqueando el reinicio.
Causa raíz
WinSW a veces no consigue terminar el proceso hijo antes de timeout, especialmente con APScheduler activo. El PID anterior persiste y el nuevo proceso no puede bindarse al puerto.
Solución
Matar el proceso directamente por puerto: $procId = (Get-NetTCPConnection -LocalPort 8091 -State Listen).OwningProcess; Stop-Process -Id $procId -Force. Nota: usar $procId, no $pid — en PowerShell $pid es variable reservada de solo lectura.
sqlite3.Row no tiene método .get()Síntoma
AttributeError: 'sqlite3.Row' object has no attribute 'get' al ejecutar la desactivación automática en exec_auto_disable. La excepción se capturaba silenciosamente y la desactivación no se realizaba.
Causa raíz
sqlite3.Row solo soporta indexación con row["campo"], no el método dict row.get("campo"). El código usaba exc.get("jira_key") heredado de cuando el objeto era un dict.
Solución
Reemplazar exc.get("jira_key") por exc["jira_key"] if exc["jira_key"] else None. Regla general: en todos los módulos, acceder a campos de sqlite3.Row solo con corchetes, nunca con .get().
Python 3 + Flask + Waitress, SQLite en WAL mode, WinSW para el servicio Windows. UI corporativa con navbar verde (#e1f56e) y botones magenta (#b62682), soporte dark/light theme.
| Fichero | Descripción |
|---|---|
app.py |
Flask app principal: rutas, middleware SSO, filtro Jinja2 madrid_time para timestamps en Europe/Madrid |
auth.py |
Token daemon MSAL (App Registration, client credentials) + auth code flow delegado + parsing del claims challenge para Protected Actions |
graph.py |
enable() → 4 operaciones Graph con retry logic; disable() → 3 operaciones de borrado con reintentos; get_user_app_roles(); get_group_members() |
db.py |
SQLite WAL mode, conexiones thread-local, auto-migración al arrancar, todas las operaciones de base de datos |
jira_client.py |
Cliente REST Jira API v2 con todos los helpers de alto nivel: create_enable_ticket, add_approval_comment, add_disable_comment, add_cancel_comment, create_expiry_ticket |
scheduler.py |
APScheduler con job diario de comprobación de expiración + run_now() para trigger manual desde el dashboard |
templates/base.html |
Shell corporativo: navbar verde (#e1f56e), botones magenta (#b62682), soporte dark/light theme |
templates/index.html |
Dashboard: lista de excepciones activas, pendientes e histórico, botón "Revisar vencidas", acciones por fila (aprobar, deshabilitar, cancelar) |
templates/request.html |
Formulario nueva solicitud: autocompletar UPN, selector multi-país con 250 códigos ISO, campo referencia Jira |
templates/approve.html |
Página de aprobación: muestra detalles de la excepción pendiente, botón "Ejecutar activación" que inicia el auth code flow |
templates/processing.html |
Página de espera con spinner mientras se ejecutan las operaciones Graph; evita doble submit |
templates/result.html |
Resultado de la activación o desactivación con resumen completo y enlace al ticket Jira |
templates/error.html |
Página de error genérico con mensaje descriptivo y enlace de vuelta al dashboard |
config/app-config.json |
Toda la configuración: host, puerto, Graph, Jira, auth MSAL, scheduler, SSO dev bypass |
app-config.json)| Clave | Descripción | Ejemplo / Valor |
|---|---|---|
host | Dirección de escucha | 0.0.0.0 |
port | Puerto del servicio | 8091 |
db_path | Ruta a la base de datos SQLite | data/travel_ca.db |
auth.tenant_id | Tenant Entra ID | d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf |
auth.client_id | App Registration client ID | 6a0be082-1ff1-45ab-ad23-16dfc08db20f |
auth.client_secret | Secreto de la App Registration | <SECRET> |
auth.redirect_uri | URI de callback MSAL (debe coincidir con la App Registration) | https://travelcaapprover-...msappproxy.net/auth/callback |
graph.domain_users_group_id | ID del grupo "Domain internal users" para autocompletar UPN | <GUID> |
graph.travel_exceptions_group_id | ID del grupo CA – Travel Exceptions | 9ef82868-2d3f-4abd-96eb-cfbc3783addc |
graph.risky_countries_policy_id | ID de la política CA principal (solo referencia, no se modifica) | 9d0adb2b-ca25-4278-80ec-c627b3611bf8 |
graph.sp_id | Object ID del Service Principal (Enterprise App) para leer roles | <GUID> |
jira.enabled | Habilita o deshabilita toda la integración Jira | true |
jira.base_url | URL base del servidor Jira | https://idealista.atlassian.net |
jira.user_email | Email del usuario de servicio Jira | [email protected] |
jira.api_token | API token de Jira Cloud | <JIRA_TOKEN> |
jira.project_key | Proyecto Jira destino para tareas generadas por la app | SIS |
daemon_graph.tenant_id | Tenant ID para el token daemon de autoservicio (SisScripts) | d78b7929-c2a3-4897-ae9a-7d8f8dc1a1cf |
daemon_graph.client_id | Client ID de SisScripts App Registration | 250c7cb4-… |
daemon_graph.thumbprint | Thumbprint del certificado en CurrentUser\My | FC402921656C6BFE… |
sso.dev_bypass_upn | UPN para saltarse el SSO en desarrollo (vaciar en producción) | "" o "[email protected]" |
scheduler.enabled | Activa el scheduler APScheduler de expiración | true |
scheduler.hour | Hora de ejecución del scheduler diario | 8 |
scheduler.timezone | Zona horaria del scheduler | Europe/Madrid |
approval_required (BD SQLite settings) | Toggle de modo: true = con aprobación + ticket SIS, false = autoservicio + token daemon. Cambiable en caliente sin reiniciar el servicio | true / false |
Todas las funcionalidades planificadas están implementadas y verificadas en producción.
Manage-TravelCAException.ps1) — Enable / Disable / ListInvoke-TravelCAException.ps1) — para tenants sin Protected Actionstravel-ca-approver — Enable y Disable verificados end-to-endapproval_required en SQLite — cambio en caliente sin reinicio250c7cb4) con autenticación por certificadodaemon_graph config section con fallback a auth